mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: initial commit
This commit is contained in:
24
api/src/analytics/analytics.module.ts
Normal file
24
api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 { Module } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
|
||||
import { BotStatsController } from './controllers/bot-stats.controller';
|
||||
import { BotStatsRepository } from './repositories/bot-stats.repository';
|
||||
import { BotStatsModel } from './schemas/bot-stats.schema';
|
||||
import { BotStatsService } from './services/bot-stats.service';
|
||||
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([BotStatsModel]), EventEmitter2],
|
||||
controllers: [BotStatsController],
|
||||
providers: [BotStatsService, BotStatsRepository],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
216
api/src/analytics/controllers/bot-stats.controller.spec.ts
Normal file
216
api/src/analytics/controllers/bot-stats.controller.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import {
|
||||
botstatsFixtures,
|
||||
installBotStatsFixtures,
|
||||
} from '@/utils/test/fixtures/botstats';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { BotStatsController } from './bot-stats.controller';
|
||||
import { BotStatsRepository } from '../repositories/bot-stats.repository';
|
||||
import { BotStatsModel, BotStatsType } from '../schemas/bot-stats.schema';
|
||||
import { BotStatsService } from '../services/bot-stats.service';
|
||||
|
||||
describe('BotStatsController', () => {
|
||||
let botStatsController: BotStatsController;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [BotStatsController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installBotStatsFixtures),
|
||||
MongooseModule.forFeature([BotStatsModel]),
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
BotStatsService,
|
||||
BotStatsRepository,
|
||||
EventEmitter2,
|
||||
],
|
||||
}).compile();
|
||||
botStatsController = module.get<BotStatsController>(BotStatsController);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeInMongodConnection();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
describe('findMessages', () => {
|
||||
it('should return no messages in the given date range', async () => {
|
||||
const result = await botStatsController.findMessages({
|
||||
from: new Date('2024-11-01T23:00:00.000Z'),
|
||||
to: new Date('2024-11-05T23:00:00.000Z'),
|
||||
});
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toEqualPayload([
|
||||
{
|
||||
id: 1,
|
||||
name: BotStatsType.all_messages,
|
||||
values: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: BotStatsType.incoming,
|
||||
values: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: BotStatsType.outgoing,
|
||||
values: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return messages in the given date range', async () => {
|
||||
const from = new Date('2023-11-01T23:00:00.000Z');
|
||||
const to = new Date('2023-11-05T23:00:00.000Z');
|
||||
const result = await botStatsController.findMessages({
|
||||
from,
|
||||
to,
|
||||
});
|
||||
expect(result).toEqualPayload([
|
||||
{
|
||||
id: 1,
|
||||
name: BotStatsType.all_messages,
|
||||
values: [
|
||||
{
|
||||
...botstatsFixtures[0],
|
||||
date: botstatsFixtures[0].day,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: BotStatsType.incoming,
|
||||
values: [
|
||||
{
|
||||
...botstatsFixtures[4],
|
||||
date: botstatsFixtures[4].day,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: BotStatsType.outgoing,
|
||||
values: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('datum', () => {
|
||||
it('should return messages of a given type', async () => {
|
||||
const result = await botStatsController.datum({
|
||||
from: new Date('2023-11-06T23:00:00.000Z'),
|
||||
to: new Date('2023-11-08T23:00:00.000Z'),
|
||||
type: BotStatsType.outgoing,
|
||||
});
|
||||
|
||||
expect(result).toEqualPayload([
|
||||
{
|
||||
id: 1,
|
||||
name: BotStatsType.outgoing,
|
||||
values: [
|
||||
{
|
||||
...botstatsFixtures[5],
|
||||
date: botstatsFixtures[5].day,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation', () => {
|
||||
it('should return conversation messages', async () => {
|
||||
const result = await botStatsController.conversation({
|
||||
from: new Date('2023-11-04T23:00:00.000Z'),
|
||||
to: new Date('2023-11-06T23:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqualPayload([
|
||||
{
|
||||
id: 1,
|
||||
name: BotStatsType.new_conversations,
|
||||
values: [
|
||||
{
|
||||
...botstatsFixtures[3],
|
||||
date: botstatsFixtures[3].day,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: BotStatsType.existing_conversations,
|
||||
values: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audiance', () => {
|
||||
it('should return audiance messages', async () => {
|
||||
const result = await botStatsController.audiance({
|
||||
from: new Date('2023-11-01T23:00:00.000Z'),
|
||||
to: new Date('2023-11-08T23:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqualPayload([
|
||||
{
|
||||
id: 1,
|
||||
name: BotStatsType.new_users,
|
||||
values: [
|
||||
{
|
||||
...botstatsFixtures[1],
|
||||
date: botstatsFixtures[1].day,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: BotStatsType.returning_users,
|
||||
values: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: BotStatsType.retention,
|
||||
values: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('popularBlocks', () => {
|
||||
it('should return popular blocks', async () => {
|
||||
const result = await botStatsController.popularBlocks({
|
||||
from: new Date('2023-11-01T23:00:00.000Z'),
|
||||
to: new Date('2023-11-08T23:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'Global Fallback',
|
||||
id: 'Global Fallback',
|
||||
value: 68,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
api/src/analytics/controllers/bot-stats.controller.ts
Normal file
119
api/src/analytics/controllers/bot-stats.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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 { Controller, Get, Query } from '@nestjs/common';
|
||||
|
||||
import { ToLinesType } from './../schemas/bot-stats.schema';
|
||||
import { BotStatsFindDatumDto, BotStatsFindDto } from '../dto/bot-stats.dto';
|
||||
import { BotStats, BotStatsType } from '../schemas/bot-stats.schema';
|
||||
import { BotStatsService } from '../services/bot-stats.service';
|
||||
import { aMonthAgo } from '../utilities';
|
||||
|
||||
@Controller('botstats')
|
||||
export class BotStatsController {
|
||||
constructor(private readonly botStatsService: BotStatsService) {}
|
||||
|
||||
/**
|
||||
* Retrieves message stats within a specified time range.
|
||||
*
|
||||
* @param dto - Parameters for filtering messages (Start & End dates).
|
||||
* @returns A promise that resolves to an array of messages formatted for the line chart.
|
||||
*/
|
||||
@Get('messages')
|
||||
async findMessages(
|
||||
@Query()
|
||||
dto: BotStatsFindDto,
|
||||
): Promise<ToLinesType[]> {
|
||||
const { from = aMonthAgo(), to = new Date() } = dto;
|
||||
const types: BotStatsType[] = [
|
||||
BotStatsType.all_messages,
|
||||
BotStatsType.incoming,
|
||||
BotStatsType.outgoing,
|
||||
];
|
||||
const result = await this.botStatsService.findMessages(from, to, types);
|
||||
return BotStats.toLines(result, types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves message stats within a specified time range for a given message type
|
||||
*
|
||||
* @param dto - Parameters for filtering data (Start & End dates, Type).
|
||||
* @returns A promise that resolves to an array of data formatted as lines.
|
||||
*/
|
||||
@Get('datum')
|
||||
async datum(
|
||||
@Query()
|
||||
dto: BotStatsFindDatumDto,
|
||||
): Promise<ToLinesType[]> {
|
||||
const { from = aMonthAgo(), to = new Date(), type } = dto;
|
||||
const result = await this.botStatsService.findMessages(from, to, [type]);
|
||||
|
||||
return BotStats.toLines(result, [type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves conversation message stats within a specified time range
|
||||
*
|
||||
* @param dto - Parameters for filtering data (Start & End dates, Type).
|
||||
* @returns A promise that resolves to an array of data formatted for the line chart.
|
||||
*/
|
||||
@Get('conversation')
|
||||
async conversation(
|
||||
@Query()
|
||||
dto: BotStatsFindDto,
|
||||
): Promise<ToLinesType[]> {
|
||||
const { from = aMonthAgo(), to = new Date() } = dto;
|
||||
const types: BotStatsType[] = [
|
||||
BotStatsType.new_conversations,
|
||||
BotStatsType.existing_conversations,
|
||||
];
|
||||
|
||||
const result = await this.botStatsService.findMessages(from, to, types);
|
||||
return BotStats.toLines(result, types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves audience message stats within a specified time range.
|
||||
*
|
||||
* @param dto - Parameters for filtering messages (Start & End dates).
|
||||
* @returns A promise that resolves to an array of data formatted for the line chart.
|
||||
*/
|
||||
@Get('audiance')
|
||||
async audiance(
|
||||
@Query()
|
||||
dto: BotStatsFindDto,
|
||||
): Promise<ToLinesType[]> {
|
||||
const { from = aMonthAgo(), to = new Date() } = dto;
|
||||
const types: BotStatsType[] = [
|
||||
BotStatsType.new_users,
|
||||
BotStatsType.returning_users,
|
||||
BotStatsType.retention,
|
||||
];
|
||||
|
||||
const result = await this.botStatsService.findMessages(from, to, types);
|
||||
return BotStats.toLines(result, types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves popular blocks stats within a specified time range.
|
||||
*
|
||||
* @param dto - Parameters for filtering messages (Start & End dates).
|
||||
* @returns A promise that resolves to an array of data formatted for the bar chart.
|
||||
*/
|
||||
@Get('popularBlocks')
|
||||
async popularBlocks(
|
||||
@Query()
|
||||
dto: BotStatsFindDto,
|
||||
): Promise<{ id: string; name: string; value: number }[]> {
|
||||
const { from = aMonthAgo(), to = new Date() } = dto;
|
||||
const results = await this.botStatsService.findPopularBlocks(from, to);
|
||||
|
||||
return BotStats.toBars(results);
|
||||
}
|
||||
}
|
||||
69
api/src/analytics/dto/bot-stats.dto.ts
Normal file
69
api/src/analytics/dto/bot-stats.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDate,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
import { BotStatsType } from '../schemas/bot-stats.schema';
|
||||
import { IsLessThanDate } from '../validation-rules/is-less-than-date';
|
||||
|
||||
export class BotStatsCreateDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
type: BotStatsType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
day: Date;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
value: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class BotStatsFindDto {
|
||||
/**
|
||||
* Start date for message retrieval.
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@IsOptional()
|
||||
@IsLessThanDate('to', {
|
||||
message: 'From date must be less than or equal to To date',
|
||||
})
|
||||
from?: Date;
|
||||
|
||||
/**
|
||||
* End date for message retrieval.
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@IsOptional()
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
export class BotStatsFindDatumDto extends BotStatsFindDto {
|
||||
/**
|
||||
* Type for message to retrieve.
|
||||
*/
|
||||
@IsEnum(BotStatsType)
|
||||
@IsNotEmpty()
|
||||
type: BotStatsType;
|
||||
}
|
||||
188
api/src/analytics/repositories/bot-stats.repository.spec.ts
Normal file
188
api/src/analytics/repositories/bot-stats.repository.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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 { MongooseModule, getModelToken } from '@nestjs/mongoose';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Model } from 'mongoose';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import {
|
||||
botstatsFixtures,
|
||||
installBotStatsFixtures,
|
||||
} from '@/utils/test/fixtures/botstats';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { BotStatsRepository } from './bot-stats.repository';
|
||||
import {
|
||||
BotStats,
|
||||
BotStatsModel,
|
||||
BotStatsType,
|
||||
} from '../schemas/bot-stats.schema';
|
||||
|
||||
describe('BotStatsRepository', () => {
|
||||
let botStatsRepository: BotStatsRepository;
|
||||
let botStatsModel: Model<BotStats>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installBotStatsFixtures),
|
||||
MongooseModule.forFeature([BotStatsModel]),
|
||||
],
|
||||
providers: [LoggerService, BotStatsRepository],
|
||||
}).compile();
|
||||
botStatsRepository = module.get<BotStatsRepository>(BotStatsRepository);
|
||||
botStatsModel = module.get<Model<BotStats>>(getModelToken('BotStats'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeInMongodConnection();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
describe('findMessages', () => {
|
||||
it('should return messages', async () => {
|
||||
jest.spyOn(botStatsModel, 'find');
|
||||
const from = new Date('2023-11-01T23:00:00.000Z');
|
||||
const to = new Date('2023-11-07T23:00:00.000Z');
|
||||
const types = [
|
||||
BotStatsType.all_messages,
|
||||
BotStatsType.incoming,
|
||||
BotStatsType.outgoing,
|
||||
];
|
||||
const result = await botStatsRepository.findMessages(from, to, types);
|
||||
|
||||
expect(botStatsModel.find).toHaveBeenCalledWith({
|
||||
type: {
|
||||
$in: [
|
||||
BotStatsType.all_messages,
|
||||
BotStatsType.incoming,
|
||||
BotStatsType.outgoing,
|
||||
],
|
||||
},
|
||||
day: { $gte: from, $lte: to },
|
||||
});
|
||||
|
||||
expect(result).toEqualPayload(
|
||||
botstatsFixtures.filter(({ type }) => types.includes(type)),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return messages of a specific period', async () => {
|
||||
jest.spyOn(botStatsModel, 'find');
|
||||
const from = new Date('2023-11-01T23:00:00.000Z');
|
||||
const to = new Date('2023-11-03T23:00:00.000Z');
|
||||
const types = [
|
||||
BotStatsType.all_messages,
|
||||
BotStatsType.incoming,
|
||||
BotStatsType.outgoing,
|
||||
];
|
||||
const result = await botStatsRepository.findMessages(from, to, types);
|
||||
|
||||
expect(botStatsModel.find).toHaveBeenCalledWith({
|
||||
type: {
|
||||
$in: [
|
||||
BotStatsType.all_messages,
|
||||
BotStatsType.incoming,
|
||||
BotStatsType.outgoing,
|
||||
],
|
||||
},
|
||||
day: { $gte: from, $lte: to },
|
||||
});
|
||||
|
||||
expect(result).toEqualPayload([botstatsFixtures[0]]);
|
||||
});
|
||||
|
||||
it('should return conversation statistics', async () => {
|
||||
jest.spyOn(botStatsModel, 'find');
|
||||
const from = new Date('2023-11-01T23:00:00.000Z');
|
||||
const to = new Date('2023-11-07T23:00:00.000Z');
|
||||
const result = await botStatsRepository.findMessages(from, to, [
|
||||
BotStatsType.new_conversations,
|
||||
BotStatsType.existing_conversations,
|
||||
]);
|
||||
|
||||
expect(botStatsModel.find).toHaveBeenCalledWith({
|
||||
type: {
|
||||
$in: [
|
||||
BotStatsType.new_conversations,
|
||||
BotStatsType.existing_conversations,
|
||||
],
|
||||
},
|
||||
day: { $gte: from, $lte: to },
|
||||
});
|
||||
|
||||
expect(result).toEqualPayload([botstatsFixtures[3]]);
|
||||
});
|
||||
|
||||
it('should return audiance statistics', async () => {
|
||||
jest.spyOn(botStatsModel, 'find');
|
||||
const from = new Date('2023-11-01T23:00:00.000Z');
|
||||
const to = new Date('2023-11-07T23:00:00.000Z');
|
||||
const result = await botStatsRepository.findMessages(from, to, [
|
||||
BotStatsType.new_users,
|
||||
BotStatsType.returning_users,
|
||||
BotStatsType.retention,
|
||||
]);
|
||||
|
||||
expect(botStatsModel.find).toHaveBeenCalledWith({
|
||||
type: {
|
||||
$in: [
|
||||
BotStatsType.new_users,
|
||||
BotStatsType.returning_users,
|
||||
BotStatsType.retention,
|
||||
],
|
||||
},
|
||||
day: { $gte: from, $lte: to },
|
||||
});
|
||||
|
||||
expect(result).toEqualPayload([botstatsFixtures[1]]);
|
||||
});
|
||||
|
||||
it('should return statistics of a given type', async () => {
|
||||
jest.spyOn(botStatsModel, 'find');
|
||||
const from = new Date('2023-11-01T23:00:00.000Z');
|
||||
const to = new Date('2023-11-07T23:00:00.000Z');
|
||||
const result = await botStatsRepository.findMessages(from, to, [
|
||||
BotStatsType.incoming,
|
||||
]);
|
||||
|
||||
expect(botStatsModel.find).toHaveBeenCalledWith({
|
||||
type: {
|
||||
$in: [BotStatsType.incoming],
|
||||
},
|
||||
day: { $gte: from, $lte: to },
|
||||
});
|
||||
|
||||
expect(result).toEqualPayload([botstatsFixtures[4]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPopularBlocks', () => {
|
||||
it('should return popular blocks', async () => {
|
||||
jest.spyOn(botStatsModel, 'aggregate');
|
||||
const from = new Date('2023-11-01T22:00:00.000Z');
|
||||
const to = new Date('2023-11-07T23:00:00.000Z');
|
||||
const result = await botStatsRepository.findPopularBlocks(from, to);
|
||||
|
||||
expect(botStatsModel.aggregate).toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'Global Fallback',
|
||||
value: 68,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
api/src/analytics/repositories/bot-stats.repository.ts
Normal file
87
api/src/analytics/repositories/bot-stats.repository.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
|
||||
import { BaseRepository } from '@/utils/generics/base-repository';
|
||||
|
||||
import { BotStats, BotStatsType } from '../schemas/bot-stats.schema';
|
||||
|
||||
@Injectable()
|
||||
export class BotStatsRepository extends BaseRepository<BotStats, never> {
|
||||
constructor(@InjectModel(BotStats.name) readonly model: Model<BotStats>) {
|
||||
super(model, BotStats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves message statistics based on the provided types and time range.
|
||||
*
|
||||
* @param from - Start date for filtering messages.
|
||||
* @param to - End date for filtering messages.
|
||||
* @param types - An array of message types to filter.
|
||||
* @returns A promise that resolves to an array of message statistics.
|
||||
*/
|
||||
async findMessages(
|
||||
from: Date,
|
||||
to: Date,
|
||||
types: BotStatsType[],
|
||||
): Promise<BotStats[]> {
|
||||
const query = this.model.find({
|
||||
type: { $in: types },
|
||||
day: { $gte: from, $lte: to },
|
||||
});
|
||||
return await this.execute(query, BotStats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the aggregated sum of values for popular blocks within a specified time range.
|
||||
*
|
||||
* @param from Start date for the time range
|
||||
* @param to End date for the time range
|
||||
* @param limit Optional maximum number of results to return (defaults to 5)
|
||||
* @returns A promise that resolves to an array of objects containing the block ID and the aggregated value
|
||||
*/
|
||||
async findPopularBlocks(
|
||||
from: Date,
|
||||
to: Date,
|
||||
limit: number = 5,
|
||||
): Promise<{ id: string; value: number }[]> {
|
||||
return await this.model.aggregate([
|
||||
{
|
||||
$match: {
|
||||
day: { $gte: from, $lte: to },
|
||||
type: BotStatsType.popular,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$name',
|
||||
id: { $sum: 1 },
|
||||
value: { $sum: '$value' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
value: -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$limit: limit,
|
||||
},
|
||||
{
|
||||
$addFields: { id: '$_id' },
|
||||
},
|
||||
{
|
||||
$project: { _id: 0 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
api/src/analytics/schemas/bot-stats.schema.spec.ts
Normal file
57
api/src/analytics/schemas/bot-stats.schema.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { botstatsFixtures } from '@/utils/test/fixtures/botstats';
|
||||
|
||||
import { BotStats, BotStatsType } from './bot-stats.schema';
|
||||
|
||||
describe('toLines', () => {
|
||||
it('should transform the data based on the given types', () => {
|
||||
const result = BotStats.toLines(
|
||||
[
|
||||
{
|
||||
...botstatsFixtures[4],
|
||||
id: '1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
...botstatsFixtures[5],
|
||||
id: '2',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[BotStatsType.incoming, BotStatsType.outgoing],
|
||||
);
|
||||
|
||||
expect(result).toEqualPayload([
|
||||
{
|
||||
id: 1,
|
||||
name: BotStatsType.incoming,
|
||||
values: [
|
||||
{
|
||||
...botstatsFixtures[4],
|
||||
date: botstatsFixtures[4].day,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: BotStatsType.outgoing,
|
||||
values: [
|
||||
{
|
||||
...botstatsFixtures[5],
|
||||
date: botstatsFixtures[5].day,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
124
api/src/analytics/schemas/bot-stats.schema.ts
Normal file
124
api/src/analytics/schemas/bot-stats.schema.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { THydratedDocument } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
|
||||
export enum BotStatsType {
|
||||
outgoing = 'outgoing',
|
||||
new_users = 'new_users',
|
||||
all_messages = 'all_messages',
|
||||
incoming = 'incoming',
|
||||
existing_conversations = 'existing_conversations',
|
||||
popular = 'popular',
|
||||
new_conversations = 'new_conversations',
|
||||
returning_users = 'returning_users',
|
||||
retention = 'retention',
|
||||
}
|
||||
|
||||
export type ToLinesType = {
|
||||
id: number;
|
||||
name: BotStatsType;
|
||||
values: any[];
|
||||
};
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class BotStats extends BaseSchema {
|
||||
/**
|
||||
* Type of the captured insight.
|
||||
*/
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
})
|
||||
type: BotStatsType;
|
||||
|
||||
/**
|
||||
* Day based granularity for the captured insights.
|
||||
*/
|
||||
@Prop({
|
||||
type: Date,
|
||||
required: true,
|
||||
})
|
||||
day: Date;
|
||||
|
||||
/**
|
||||
* Total value of the insight for the whole chosen granularity.
|
||||
*/
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
value?: number;
|
||||
|
||||
/**
|
||||
* name of the insight (e.g: incoming messages).
|
||||
*/
|
||||
@Prop({ type: String, required: true })
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Converts bot statistics data into an line chart data format.
|
||||
*
|
||||
* @param stats - The array of bot statistics.
|
||||
* @param types - The array of bot statistics types.
|
||||
* @returns An array of data representing the bot statistics data.
|
||||
*/
|
||||
static toLines(stats: BotStats[], types: BotStatsType[]): ToLinesType[] {
|
||||
const data = types.map((type, index) => {
|
||||
return {
|
||||
id: index + 1,
|
||||
name: type,
|
||||
values: [],
|
||||
};
|
||||
});
|
||||
|
||||
const index: { [dataName: string]: number } = data.reduce(
|
||||
(acc, curr, i) => {
|
||||
acc[curr.name] = i;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const result = stats.reduce((acc, stat: BotStats & { date: Date }) => {
|
||||
stat.date = stat.day;
|
||||
acc[index[stat.type]].values.push(stat);
|
||||
return acc;
|
||||
}, data);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts fetched stats to a bar chart compatible data format
|
||||
*
|
||||
* @param stats - Array of objects, each contaning at least an id and a value
|
||||
* @returns BarChart compatible data
|
||||
*/
|
||||
static toBars(
|
||||
stats: { id: string; value: number }[],
|
||||
): { id: string; name: string; value: number }[] {
|
||||
return stats.map((stat) => {
|
||||
return {
|
||||
...stat,
|
||||
name: stat.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type BotStatsDocument = THydratedDocument<BotStats>;
|
||||
|
||||
export const BotStatsModel: ModelDefinition = {
|
||||
name: BotStats.name,
|
||||
schema: SchemaFactory.createForClass(BotStats),
|
||||
};
|
||||
|
||||
export default BotStatsModel.schema;
|
||||
122
api/src/analytics/services/bot-stats.service.spec.ts
Normal file
122
api/src/analytics/services/bot-stats.service.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import {
|
||||
botstatsFixtures,
|
||||
installBotStatsFixtures,
|
||||
} from '@/utils/test/fixtures/botstats';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { BotStatsService } from './bot-stats.service';
|
||||
import { BotStatsRepository } from '../repositories/bot-stats.repository';
|
||||
import { BotStatsModel, BotStatsType } from '../schemas/bot-stats.schema';
|
||||
|
||||
describe('BotStatsService', () => {
|
||||
let botStatsService: BotStatsService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installBotStatsFixtures),
|
||||
MongooseModule.forFeature([BotStatsModel]),
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
BotStatsService,
|
||||
BotStatsRepository,
|
||||
EventEmitter2,
|
||||
],
|
||||
}).compile();
|
||||
botStatsService = module.get<BotStatsService>(BotStatsService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeInMongodConnection();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
describe('findMessages', () => {
|
||||
it('should return all messages', async () => {
|
||||
const from = botstatsFixtures[0].day;
|
||||
const to = new Date();
|
||||
const result = await botStatsService.findMessages(
|
||||
from,
|
||||
to,
|
||||
Object.values(BotStatsType),
|
||||
);
|
||||
|
||||
expect(result).toEqualPayload(botstatsFixtures);
|
||||
});
|
||||
|
||||
it('should return messages between the given date range', async () => {
|
||||
const from = botstatsFixtures[0].day;
|
||||
const to = botstatsFixtures[2].day;
|
||||
const result = await botStatsService.findMessages(
|
||||
from,
|
||||
to,
|
||||
Object.values(BotStatsType),
|
||||
);
|
||||
expect(result).toEqualPayload(botstatsFixtures.slice(0, 3));
|
||||
});
|
||||
|
||||
it('should return messages of a given type', async () => {
|
||||
const from = botstatsFixtures[0].day;
|
||||
const to = new Date();
|
||||
const result = await botStatsService.findMessages(from, to, [
|
||||
BotStatsType.outgoing,
|
||||
]);
|
||||
expect(result).toEqualPayload([botstatsFixtures[5]]);
|
||||
});
|
||||
|
||||
it('should return messages of type conversation', async () => {
|
||||
const from = botstatsFixtures[0].day;
|
||||
const to = new Date();
|
||||
const result = await botStatsService.findMessages(from, to, [
|
||||
BotStatsType.new_conversations,
|
||||
BotStatsType.existing_conversations,
|
||||
]);
|
||||
expect(result).toEqualPayload([botstatsFixtures[3]]);
|
||||
});
|
||||
|
||||
it('should return messages of type audiance', async () => {
|
||||
const from = botstatsFixtures[0].day;
|
||||
const to = new Date();
|
||||
const result = await botStatsService.findMessages(from, to, [
|
||||
BotStatsType.new_users,
|
||||
BotStatsType.returning_users,
|
||||
BotStatsType.retention,
|
||||
]);
|
||||
expect(result).toEqualPayload([botstatsFixtures[1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPopularBlocks', () => {
|
||||
it('should return popular blocks', async () => {
|
||||
const from = botstatsFixtures[0].day;
|
||||
const to = new Date();
|
||||
const result = await botStatsService.findPopularBlocks(from, to);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'Global Fallback',
|
||||
value: 68,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
136
api/src/analytics/services/bot-stats.service.ts
Normal file
136
api/src/analytics/services/bot-stats.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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 { Subscriber } from '@/chat/schemas/subscriber.schema';
|
||||
import { config } from '@/config';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
|
||||
import { BotStatsRepository } from '../repositories/bot-stats.repository';
|
||||
import { BotStats, BotStatsType } from '../schemas/bot-stats.schema';
|
||||
|
||||
@Injectable()
|
||||
export class BotStatsService extends BaseService<BotStats> {
|
||||
constructor(
|
||||
readonly repository: BotStatsRepository,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves statistics for messages within a specified time range and of specified types.
|
||||
*
|
||||
* @param from - The start date for filtering messages.
|
||||
* @param to - The end date for filtering messages.
|
||||
* @param types - An array of message types (of type BotStatsType) to filter the statistics.
|
||||
*
|
||||
* @returns A promise that resolves to an array of `BotStats` objects representing the message statistics.
|
||||
*/
|
||||
async findMessages(
|
||||
from: Date,
|
||||
to: Date,
|
||||
types: BotStatsType[],
|
||||
): Promise<BotStats[]> {
|
||||
return await this.repository.findMessages(from, to, types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the most popular blocks within a specified time range.
|
||||
* Popular blocks are those triggered the most frequently.
|
||||
*
|
||||
* @param from - The start date of the time range.
|
||||
* @param to - The end date of the time range.
|
||||
* @returns A promise that resolves with an array of popular blocks, each containing an `id` and the number of times it was triggered (`value`).
|
||||
*/
|
||||
async findPopularBlocks(
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<{ id: string; value: number }[]> {
|
||||
return await this.repository.findPopularBlocks(from, to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the event to track user activity and emit statistics for loyalty, returning users, and retention.
|
||||
*
|
||||
* This method checks the last visit of the subscriber and emits relevant analytics events
|
||||
* based on configured thresholds for loyalty, returning users, and retention.
|
||||
*
|
||||
* @param {Subscriber} subscriber - The subscriber object that contains last visit and retention data.
|
||||
*/
|
||||
@OnEvent('hook:user:lastvisit')
|
||||
private handleLastVisit(subscriber: Subscriber) {
|
||||
const now = +new Date();
|
||||
if (subscriber.lastvisit) {
|
||||
// A loyal subscriber is a subscriber that comes back after some inactivity
|
||||
if (now - +subscriber.lastvisit > config.analytics.thresholds.loyalty) {
|
||||
this.eventEmitter.emit(
|
||||
'hook:stats:entry',
|
||||
'returning_users',
|
||||
'Loyalty',
|
||||
subscriber,
|
||||
);
|
||||
}
|
||||
|
||||
// Returning subscriber is a subscriber that comes back after some inactivity
|
||||
if (now - +subscriber.lastvisit > config.analytics.thresholds.returning) {
|
||||
this.eventEmitter.emit(
|
||||
'hook:stats:entry',
|
||||
'returning_users',
|
||||
'Returning users',
|
||||
);
|
||||
}
|
||||
}
|
||||
// Retention
|
||||
if (
|
||||
subscriber.retainedFrom &&
|
||||
now - +subscriber.retainedFrom > config.analytics.thresholds.retention
|
||||
) {
|
||||
this.eventEmitter.emit(
|
||||
'hook:stats:entry',
|
||||
'retention',
|
||||
'Retentioned users',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the event to update bot statistics.
|
||||
*
|
||||
* @param type - The type of bot statistics being tracked (e.g., user messages, bot responses).
|
||||
* @param name - The name or identifier of the statistics entry (e.g., a specific feature or component being tracked).
|
||||
*/
|
||||
@OnEvent('hook:stats:entry')
|
||||
private async handleStatEntry(type: BotStatsType, name: string) {
|
||||
const day = new Date();
|
||||
day.setMilliseconds(0);
|
||||
day.setSeconds(0);
|
||||
day.setMinutes(0);
|
||||
day.setHours(0);
|
||||
|
||||
try {
|
||||
const insight = await this.findOneOrCreate(
|
||||
{ day: { $lte: day, $gte: day }, type, name },
|
||||
{ day, type, name, value: 0 },
|
||||
);
|
||||
|
||||
try {
|
||||
await this.updateOne(insight.id, { value: insight.value + 1 });
|
||||
} catch (err) {
|
||||
this.logger.error('Stats hook : Unable to update insight', err);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Stats hook : Unable to find or create insight', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
api/src/analytics/utilities/index.ts
Normal file
11
api/src/analytics/utilities/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const aMonthAgo = (): Date =>
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1));
|
||||
38
api/src/analytics/validation-rules/is-less-than-date.ts
Normal file
38
api/src/analytics/validation-rules/is-less-than-date.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 {
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
ValidationArguments,
|
||||
} from 'class-validator';
|
||||
|
||||
export function IsLessThanDate(
|
||||
property: string,
|
||||
validationOptions?: ValidationOptions,
|
||||
) {
|
||||
return (object: unknown, propertyName: string) => {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
constraints: [property],
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(value: any, args: ValidationArguments) {
|
||||
const [relatedPropertyName] = args.constraints;
|
||||
const relatedValue = args.object[relatedPropertyName];
|
||||
if (relatedValue) {
|
||||
return value <= relatedValue;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
73
api/src/app.controller.ts
Normal file
73
api/src/app.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Req,
|
||||
Res,
|
||||
Session,
|
||||
} from '@nestjs/common';
|
||||
import { CsrfCheck, CsrfGenAuth } from '@tekuconcept/nestjs-csrf';
|
||||
import { CsrfGenerator } from '@tekuconcept/nestjs-csrf/dist/csrf.generator';
|
||||
import { Request, Response } from 'express';
|
||||
import { Session as ExpressSession } from 'express-session';
|
||||
|
||||
import { AppService } from './app.service';
|
||||
import { config } from './config';
|
||||
import { LoggerService } from './logger/logger.service';
|
||||
import { Roles } from './utils/decorators/roles.decorator';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(
|
||||
private readonly appService: AppService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@Roles('public')
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Roles('public')
|
||||
@Get('csrftoken')
|
||||
@CsrfCheck(false)
|
||||
@CsrfGenAuth(true)
|
||||
csrf(@Session() session: ExpressSession) {
|
||||
return {
|
||||
_csrf: session?.csrfSecret
|
||||
? new CsrfGenerator().create(session.csrfSecret)
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
@Roles('public')
|
||||
@Get('__getcookie')
|
||||
cookies(@Req() req: Request): string {
|
||||
req.session.anonymous = true;
|
||||
return '_sailsIoJSConnect();';
|
||||
}
|
||||
|
||||
// @TODO : remove once old frontend is abandoned
|
||||
@Get('logout')
|
||||
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
res.clearCookie(config.session.name);
|
||||
|
||||
req.session.destroy((error) => {
|
||||
if (error) {
|
||||
this.logger.error(error);
|
||||
throw new BadRequestException();
|
||||
}
|
||||
});
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
139
api/src/app.module.ts
Normal file
139
api/src/app.module.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 path from 'path';
|
||||
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { MjmlAdapter } from '@nestjs-modules/mailer/dist/adapters/mjml.adapter';
|
||||
import { CsrfGuard, CsrfModule } from '@tekuconcept/nestjs-csrf';
|
||||
import {
|
||||
AcceptLanguageResolver,
|
||||
I18nOptions,
|
||||
QueryResolver,
|
||||
} from 'nestjs-i18n';
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AttachmentModule } from './attachment/attachment.module';
|
||||
import { ChannelModule } from './channel/channel.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { CmsModule } from './cms/cms.module';
|
||||
import { config } from './config';
|
||||
import { ExtendedI18nModule } from './extended-18n.module';
|
||||
import { LoggerModule } from './logger/logger.module';
|
||||
import { DtoUpdateMiddleware } from './middlewares/dto.update.middleware';
|
||||
import { NlpModule } from './nlp/nlp.module';
|
||||
import { PluginsModule } from './plugins/plugins.module';
|
||||
import { SettingModule } from './setting/setting.module';
|
||||
import { Ability } from './user/guards/ability.guard';
|
||||
import { UserModule } from './user/user.module';
|
||||
import idPlugin from './utils/schema-plugin/id.plugin';
|
||||
import { WebsocketModule } from './websocket/websocket.module';
|
||||
|
||||
const i18nOptions: I18nOptions = {
|
||||
fallbackLanguage: config.chatbot.lang.default,
|
||||
loaderOptions: {
|
||||
path: path.join(__dirname, '/config/i18n/'),
|
||||
watch: true,
|
||||
},
|
||||
resolvers: [
|
||||
{ use: QueryResolver, options: ['lang'] },
|
||||
AcceptLanguageResolver,
|
||||
],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MailerModule.forRoot({
|
||||
transport: new SMTPTransport({
|
||||
...config.emails.smtp,
|
||||
logger: true,
|
||||
}),
|
||||
template: {
|
||||
adapter: new MjmlAdapter('ejs', { inlineCssEnabled: false }),
|
||||
dir: './src/templates',
|
||||
options: {
|
||||
context: {
|
||||
appName: config.parameters.appName,
|
||||
appUrl: config.parameters.appUrl,
|
||||
// TODO: add i18n support
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: { from: config.parameters.email.main },
|
||||
}),
|
||||
MongooseModule.forRoot(config.mongo.uri, {
|
||||
dbName: config.mongo.dbName,
|
||||
connectionFactory: (connection) => {
|
||||
connection.plugin(idPlugin);
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
connection.plugin(require('mongoose-lean-virtuals'));
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
connection.plugin(require('mongoose-lean-getters'));
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
connection.plugin(require('mongoose-lean-defaults').default);
|
||||
return connection;
|
||||
},
|
||||
}),
|
||||
NlpModule,
|
||||
CmsModule,
|
||||
UserModule,
|
||||
SettingModule,
|
||||
AttachmentModule,
|
||||
AnalyticsModule,
|
||||
ChatModule,
|
||||
ChannelModule,
|
||||
PluginsModule,
|
||||
LoggerModule,
|
||||
WebsocketModule,
|
||||
EventEmitterModule.forRoot({
|
||||
// set this to `true` to use wildcards
|
||||
wildcard: true,
|
||||
// the delimiter used to segment namespaces
|
||||
delimiter: ':',
|
||||
// set this to `true` if you want to emit the newListener event
|
||||
newListener: false,
|
||||
// set this to `true` if you want to emit the removeListener event
|
||||
removeListener: false,
|
||||
// the maximum amount of listeners that can be assigned to an event
|
||||
maxListeners: 10,
|
||||
// show event name in memory leak message when more than maximum amount of listeners is assigned
|
||||
verboseMemoryLeak: false,
|
||||
// disable throwing uncaughtException if an error event is emitted and it has no listeners
|
||||
ignoreErrors: false,
|
||||
}),
|
||||
CsrfModule,
|
||||
ExtendedI18nModule.forRoot(i18nOptions),
|
||||
CacheModule.register({
|
||||
isGlobal: true,
|
||||
ttl: config.cache.ttl,
|
||||
max: config.cache.max,
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: Ability },
|
||||
{ provide: APP_GUARD, useClass: CsrfGuard },
|
||||
AppService,
|
||||
],
|
||||
})
|
||||
export class AppModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(DtoUpdateMiddleware)
|
||||
.forRoutes({ path: '*', method: RequestMethod.PATCH });
|
||||
}
|
||||
}
|
||||
26
api/src/app.service.ts
Normal file
26
api/src/app.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 { ExtendedI18nService } from './extended-i18n.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
constructor(
|
||||
private readonly i18n: ExtendedI18nService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
getHello(): string {
|
||||
this.eventEmitter.emit('hook:i18n:refresh', []);
|
||||
return this.i18n.t('Welcome');
|
||||
}
|
||||
}
|
||||
30
api/src/attachment/attachment.module.ts
Normal file
30
api/src/attachment/attachment.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
|
||||
import { AttachmentController } from './controllers/attachment.controller';
|
||||
import { AttachmentRepository } from './repositories/attachment.repository';
|
||||
import { AttachmentModel } from './schemas/attachment.schema';
|
||||
import { AttachmentService } from './services/attachment.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([AttachmentModel]),
|
||||
PassportModule.register({
|
||||
session: true,
|
||||
}),
|
||||
],
|
||||
providers: [AttachmentRepository, AttachmentService],
|
||||
controllers: [AttachmentController],
|
||||
exports: [AttachmentService],
|
||||
})
|
||||
export class AttachmentModule {}
|
||||
166
api/src/attachment/controllers/attachment.controller.spec.ts
Normal file
166
api/src/attachment/controllers/attachment.controller.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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 { BadRequestException } from '@nestjs/common/exceptions';
|
||||
import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { NOT_FOUND_ID } from '@/utils/constants/mock';
|
||||
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
|
||||
import {
|
||||
attachmentFixtures,
|
||||
installAttachmentFixtures,
|
||||
} from '@/utils/test/fixtures/attachment';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { AttachmentController } from './attachment.controller';
|
||||
import { attachment, attachmentFile } from '../mocks/attachment.mock';
|
||||
import { AttachmentRepository } from '../repositories/attachment.repository';
|
||||
import { AttachmentModel, Attachment } from '../schemas/attachment.schema';
|
||||
import { AttachmentService } from '../services/attachment.service';
|
||||
|
||||
describe('AttachmentController', () => {
|
||||
let attachmentController: AttachmentController;
|
||||
let attachmentService: AttachmentService;
|
||||
let attachmentToDelete: Attachment;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AttachmentController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installAttachmentFixtures),
|
||||
MongooseModule.forFeature([AttachmentModel]),
|
||||
],
|
||||
providers: [
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
LoggerService,
|
||||
EventEmitter2,
|
||||
PluginService,
|
||||
],
|
||||
}).compile();
|
||||
attachmentController =
|
||||
module.get<AttachmentController>(AttachmentController);
|
||||
attachmentService = module.get<AttachmentService>(AttachmentService);
|
||||
attachmentToDelete = await attachmentService.findOne({
|
||||
name: 'store1.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeInMongodConnection();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
describe('count', () => {
|
||||
it('should count attachments', async () => {
|
||||
jest.spyOn(attachmentService, 'count');
|
||||
const result = await attachmentController.filterCount();
|
||||
|
||||
expect(attachmentService.count).toHaveBeenCalled();
|
||||
expect(result).toEqual({ count: attachmentFixtures.length });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upload', () => {
|
||||
it('should throw BadRequestException if no file is selected to be uploaded', async () => {
|
||||
const promiseResult = attachmentController.uploadFile({
|
||||
file: undefined,
|
||||
});
|
||||
await expect(promiseResult).rejects.toThrow(
|
||||
new BadRequestException('No file was selected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should upload attachment', async () => {
|
||||
jest.spyOn(attachmentService, 'create');
|
||||
const result = await attachmentController.uploadFile({
|
||||
file: [attachmentFile],
|
||||
});
|
||||
expect(attachmentService.create).toHaveBeenCalledWith({
|
||||
size: attachmentFile.size,
|
||||
type: attachmentFile.mimetype,
|
||||
name: attachmentFile.filename,
|
||||
channel: {},
|
||||
location: `/${attachmentFile.filename}`,
|
||||
});
|
||||
expect(result).toEqualPayload(
|
||||
[attachment],
|
||||
[...IGNORED_TEST_FIELDS, 'url'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download', () => {
|
||||
it(`should throw NotFoundException the id or/and file don't exist`, async () => {
|
||||
jest.spyOn(attachmentService, 'findOne');
|
||||
const result = attachmentController.download({ id: NOT_FOUND_ID });
|
||||
expect(attachmentService.findOne).toHaveBeenCalledWith(NOT_FOUND_ID);
|
||||
expect(result).rejects.toThrow(
|
||||
new NotFoundException('Attachment not found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should download the attachment by id', async () => {
|
||||
jest.spyOn(attachmentService, 'findOne');
|
||||
const storedAttachment = await attachmentService.findOne({
|
||||
name: 'store1.jpg',
|
||||
});
|
||||
const result = await attachmentController.download({
|
||||
id: storedAttachment.id,
|
||||
});
|
||||
|
||||
expect(attachmentService.findOne).toHaveBeenCalledWith(
|
||||
storedAttachment.id,
|
||||
);
|
||||
expect(result.options).toEqual({
|
||||
type: storedAttachment.type,
|
||||
length: storedAttachment.size,
|
||||
disposition: `attachment; filename="${encodeURIComponent(
|
||||
storedAttachment.name,
|
||||
)}"`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne', () => {
|
||||
it('should delete an attachment by id', async () => {
|
||||
jest.spyOn(attachmentService, 'deleteOne');
|
||||
const result = await attachmentController.deleteOne(
|
||||
attachmentToDelete.id,
|
||||
);
|
||||
|
||||
expect(attachmentService.deleteOne).toHaveBeenCalledWith(
|
||||
attachmentToDelete.id,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
acknowledged: true,
|
||||
deletedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a NotFoundException when attempting to delete an attachment by id', async () => {
|
||||
await expect(
|
||||
attachmentController.deleteOne(attachmentToDelete.id),
|
||||
).rejects.toThrow(
|
||||
new NotFoundException(
|
||||
`Attachment with ID ${attachmentToDelete.id} not found`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
api/src/attachment/controllers/attachment.controller.ts
Normal file
177
api/src/attachment/controllers/attachment.controller.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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 { extname } from 'path';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
import { diskStorage, memoryStorage } from 'multer';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { config } from '@/config';
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { Roles } from '@/utils/decorators/roles.decorator';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
import { DeleteResult } from '@/utils/generics/base-repository';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
|
||||
import { AttachmentDownloadDto } from '../dto/attachment.dto';
|
||||
import { Attachment } from '../schemas/attachment.schema';
|
||||
import { AttachmentService } from '../services/attachment.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('attachment')
|
||||
export class AttachmentController extends BaseController<Attachment> {
|
||||
constructor(
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(attachmentService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the filtered number of attachments.
|
||||
*
|
||||
* @returns A promise that resolves to an object representing the filtered number of attachments.
|
||||
*/
|
||||
@Get('count')
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<Attachment>({
|
||||
allowedFields: ['name', 'type'],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<Attachment>,
|
||||
) {
|
||||
return await this.count(filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<Attachment> {
|
||||
const doc = await this.attachmentService.findOne(id);
|
||||
if (!doc) {
|
||||
this.logger.warn(`Unable to find Attachement by id ${id}`);
|
||||
throw new NotFoundException(`Attachement with ID ${id} not found`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all attachments based on specified filters.
|
||||
*
|
||||
* @param pageQuery - The pagination to apply when retrieving attachments.
|
||||
* @param filters - The filters to apply when retrieving attachments.
|
||||
* @returns A promise that resolves to an array of attachments matching the filters.
|
||||
*/
|
||||
@Get()
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Attachment>,
|
||||
@Query(
|
||||
new SearchFilterPipe<Attachment>({ allowedFields: ['name', 'type'] }),
|
||||
)
|
||||
filters: TFilterQuery<Attachment>,
|
||||
) {
|
||||
return await this.attachmentService.findPage(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files to the server.
|
||||
*
|
||||
* @param files - An array of files to upload.
|
||||
* @returns A promise that resolves to an array of uploaded attachments.
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileFieldsInterceptor([{ name: 'file' }], {
|
||||
limits: {
|
||||
fileSize: config.parameters.maxUploadSize,
|
||||
},
|
||||
storage: (() => {
|
||||
if (config.parameters.storageMode === 'memory') {
|
||||
return memoryStorage();
|
||||
} else {
|
||||
return diskStorage({
|
||||
destination: config.parameters.uploadDir,
|
||||
filename: (req, file, cb) => {
|
||||
const name = file.originalname.split('.')[0];
|
||||
const extension = extname(file.originalname);
|
||||
cb(null, `${name}-${uuidv4()}${extension}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
})(),
|
||||
}),
|
||||
)
|
||||
async uploadFile(
|
||||
@UploadedFiles() files: { file: Express.Multer.File[] },
|
||||
): Promise<Attachment[]> {
|
||||
if (!files || !Array.isArray(files?.file) || files.file.length === 0) {
|
||||
throw new BadRequestException('No file was selected');
|
||||
}
|
||||
|
||||
return await this.attachmentService.uploadFiles(files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an attachment identified by the provided parameters.
|
||||
*
|
||||
* @param params - The parameters identifying the attachment to download.
|
||||
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
|
||||
*/
|
||||
@Roles('public')
|
||||
@Get('download/:id/:filename?')
|
||||
async download(
|
||||
@Param() params: AttachmentDownloadDto,
|
||||
): Promise<StreamableFile> {
|
||||
const attachment = await this.attachmentService.findOne(params.id);
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
}
|
||||
|
||||
return this.attachmentService.download(attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an attachment with the specified ID.
|
||||
*
|
||||
* @param id - The ID of the attachment to delete.
|
||||
* @returns A promise that resolves to the result of the deletion operation.
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
|
||||
const result = await this.attachmentService.deleteOne(id);
|
||||
if (result.deletedCount === 0) {
|
||||
this.logger.warn(`Unable to delete attachment by id ${id}`);
|
||||
throw new NotFoundException(`Attachment with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
75
api/src/attachment/dto/attachment.dto.ts
Normal file
75
api/src/attachment/dto/attachment.dto.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsObject,
|
||||
IsOptional,
|
||||
MaxLength,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
import { ObjectIdDto } from '@/utils/dto/object-id.dto';
|
||||
|
||||
export class AttachmentCreateDto {
|
||||
/**
|
||||
* Attachment channel
|
||||
*/
|
||||
@ApiPropertyOptional({ description: 'Attachment channel', type: Object })
|
||||
@IsNotEmpty()
|
||||
@IsObject()
|
||||
channel?: Partial<Record<string, any>>;
|
||||
|
||||
/**
|
||||
* Attachment location
|
||||
*/
|
||||
@ApiProperty({ description: 'Attachment location', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
location: string;
|
||||
|
||||
/**
|
||||
* Attachment name
|
||||
*/
|
||||
@ApiProperty({ description: 'Attachment name', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Attachment size
|
||||
*/
|
||||
@ApiProperty({ description: 'Attachment size', type: Number })
|
||||
@IsNotEmpty()
|
||||
size: number;
|
||||
|
||||
/**
|
||||
* Attachment type
|
||||
*/
|
||||
@ApiProperty({ description: 'Attachment type', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
type: string;
|
||||
}
|
||||
|
||||
export class AttachmentDownloadDto extends ObjectIdDto {
|
||||
/**
|
||||
* Attachment file name
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Attachment download filename',
|
||||
type: String,
|
||||
})
|
||||
@Type(() => String)
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
filename?: string;
|
||||
}
|
||||
62
api/src/attachment/mocks/attachment.mock.ts
Normal file
62
api/src/attachment/mocks/attachment.mock.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 { Stream } from 'node:stream';
|
||||
|
||||
import { Attachment } from '../schemas/attachment.schema';
|
||||
|
||||
export const attachment: Attachment = {
|
||||
name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
|
||||
type: 'image/png',
|
||||
size: 343370,
|
||||
location:
|
||||
'/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
|
||||
id: '65940d115178607da65c82b6',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
export const attachmentFile: Express.Multer.File = {
|
||||
filename: attachment.name,
|
||||
mimetype: attachment.type,
|
||||
size: attachment.size,
|
||||
buffer: Buffer.from(new Uint8Array([])),
|
||||
destination: '',
|
||||
fieldname: '',
|
||||
originalname: '',
|
||||
path: '',
|
||||
stream: new Stream.Readable(),
|
||||
encoding: '7bit',
|
||||
};
|
||||
|
||||
export const attachments: Attachment[] = [
|
||||
attachment,
|
||||
{
|
||||
name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
|
||||
type: 'image/png',
|
||||
size: 343370,
|
||||
location:
|
||||
'/app/src/attachment/uploads/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
|
||||
channel: { dimelo: {} },
|
||||
id: '65940d115178607da65c82b7',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
name: 'Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png',
|
||||
type: 'image/png',
|
||||
size: 33829,
|
||||
location:
|
||||
'/app/src/attachment/uploads/Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png',
|
||||
channel: { dimelo: {} },
|
||||
id: '65940d115178607da65c82b8',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
36
api/src/attachment/repositories/attachment.repository.ts
Normal file
36
api/src/attachment/repositories/attachment.repository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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 { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
|
||||
import { BaseRepository } from '@/utils/generics/base-repository';
|
||||
|
||||
import { Attachment, AttachmentDocument } from '../schemas/attachment.schema';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentRepository extends BaseRepository<Attachment, never> {
|
||||
constructor(
|
||||
@InjectModel(Attachment.name) readonly model: Model<Attachment>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {
|
||||
super(model, Attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles post-creation operations for an attachment.
|
||||
*
|
||||
* @param created - The created attachment document.
|
||||
*/
|
||||
async postCreate(created: AttachmentDocument): Promise<void> {
|
||||
this.eventEmitter.emit('hook:chatbot:attachment:upload', created);
|
||||
}
|
||||
}
|
||||
127
api/src/attachment/schemas/attachment.schema.ts
Normal file
127
api/src/attachment/schemas/attachment.schema.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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 { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { THydratedDocument } from 'mongoose';
|
||||
|
||||
import { FileType } from '@/chat/schemas/types/attachment';
|
||||
import { config } from '@/config';
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
|
||||
import { MIME_REGEX } from '../utilities';
|
||||
|
||||
// TODO: Interface AttachmentAttrs declared, currently not used
|
||||
|
||||
export interface AttachmentAttrs {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
location: string;
|
||||
channel?: Record<string, any>;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Attachment extends BaseSchema {
|
||||
/**
|
||||
* The name of the attachment.
|
||||
*/
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
})
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The MIME type of the attachment, must match the MIME_REGEX.
|
||||
*/
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
match: MIME_REGEX,
|
||||
})
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* The size of the attachment in bytes, must be between 0 and config.parameters.maxUploadSize.
|
||||
*/
|
||||
@Prop({
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0,
|
||||
max: config.parameters.maxUploadSize,
|
||||
})
|
||||
size: number;
|
||||
|
||||
/**
|
||||
* The location of the attachment, must be a unique value and pass the fileExists validation.
|
||||
*/
|
||||
@Prop({
|
||||
type: String,
|
||||
unique: true,
|
||||
})
|
||||
location: string;
|
||||
|
||||
/**
|
||||
* Optional property representing the attachment channel, can hold a partial record of various channel data.
|
||||
*/
|
||||
@Prop({ type: JSON })
|
||||
channel?: Partial<Record<string, any>>;
|
||||
|
||||
/**
|
||||
* Optional property representing the URL of the attachment.
|
||||
*
|
||||
*/
|
||||
url?: string;
|
||||
|
||||
/**
|
||||
* Generates and returns the URL of the attachment.
|
||||
* @param attachmentId - Id of the attachment
|
||||
* @param attachmentName - The file name of the attachment. Optional and defaults to an empty string.
|
||||
* @returns A string representing the attachment URL
|
||||
*/
|
||||
static getAttachmentUrl(
|
||||
attachmentId: string,
|
||||
attachmentName: string = '',
|
||||
): string {
|
||||
return `${config.parameters.apiUrl}/attachment/download/${attachmentId}/${attachmentName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the type of the attachment based on its MIME type.
|
||||
* @param mimeType - The MIME Type of the attachment (eg. image/png)
|
||||
* @returns The attachment type ('image', 'audio', 'video' or 'file')
|
||||
*/
|
||||
static getTypeByMime(mimeType: string): FileType {
|
||||
if (mimeType.startsWith(FileType.image)) {
|
||||
return FileType.image;
|
||||
} else if (mimeType.startsWith(FileType.audio)) {
|
||||
return FileType.audio;
|
||||
} else if (mimeType.startsWith(FileType.video)) {
|
||||
return FileType.video;
|
||||
} else {
|
||||
return FileType.file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type AttachmentDocument = THydratedDocument<Attachment>;
|
||||
|
||||
export const AttachmentModel: ModelDefinition = {
|
||||
name: Attachment.name,
|
||||
schema: SchemaFactory.createForClass(Attachment),
|
||||
};
|
||||
|
||||
AttachmentModel.schema.virtual('url').get(function () {
|
||||
if (this._id && this.name)
|
||||
return `${config.apiPath}/attachment/download/${this._id}/${this.name}`;
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
export default AttachmentModel.schema;
|
||||
212
api/src/attachment/services/attachment.service.ts
Normal file
212
api/src/attachment/services/attachment.service.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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 fs, { createReadStream } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { config } from '@/config';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { PluginInstance } from '@/plugins/map-types';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { PluginType } from '@/plugins/types';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
|
||||
import { AttachmentRepository } from '../repositories/attachment.repository';
|
||||
import { Attachment } from '../schemas/attachment.schema';
|
||||
import { fileExists, getStreamableFile } from '../utilities';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService extends BaseService<Attachment> {
|
||||
private storagePlugin: PluginInstance<PluginType.storage> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly repository: AttachmentRepository,
|
||||
private readonly logger: LoggerService,
|
||||
@Optional() private readonly pluginService: PluginService,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* A storage plugin is a alternative way to store files, instead of local filesystem, you can
|
||||
* have a plugin that would store files in a 3rd party system (Minio, AWS S3, ...)
|
||||
*
|
||||
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
|
||||
* @returns A singleton instance of the storage plugin
|
||||
*/
|
||||
getStoragePlugin() {
|
||||
if (!this.storagePlugin) {
|
||||
const plugins = this.pluginService.getAllByType(PluginType.storage);
|
||||
|
||||
if (plugins.length === 1) {
|
||||
this.storagePlugin = plugins[0];
|
||||
} else if (plugins.length > 1) {
|
||||
throw new Error(
|
||||
'Multiple storage plugins are detected, please ensure only one is available',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.storagePlugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration.
|
||||
*
|
||||
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
|
||||
* @returns A `StreamableFile` containing the user's profile picture.
|
||||
*/
|
||||
async downloadProfilePic(foreign_id: string): Promise<StreamableFile> {
|
||||
if (this.getStoragePlugin()) {
|
||||
try {
|
||||
const pict = foreign_id + '.jpeg';
|
||||
const picture = await this.getStoragePlugin().downloadProfilePic(pict);
|
||||
return picture;
|
||||
} catch (err) {
|
||||
this.logger.error('Error downloading profile picture', err);
|
||||
throw new NotFoundException('Profile picture not found');
|
||||
}
|
||||
} else {
|
||||
const path = join(config.parameters.avatarDir, `${foreign_id}.jpeg`);
|
||||
if (fs.existsSync(path)) {
|
||||
const picturetream = createReadStream(path);
|
||||
return new StreamableFile(picturetream);
|
||||
} else {
|
||||
throw new NotFoundException('Profile picture not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile picture to either 3rd party storage system or locally based on the configuration.
|
||||
*
|
||||
* @param res - The response object from which the profile picture will be buffered or piped.
|
||||
* @param filename - The filename
|
||||
*/
|
||||
async uploadProfilePic(res: fetch.Response, filename: string) {
|
||||
if (this.getStoragePlugin()) {
|
||||
// Upload profile picture
|
||||
const buffer = await res.buffer();
|
||||
const picture = {
|
||||
originalname: filename,
|
||||
buffer,
|
||||
} as Express.Multer.File;
|
||||
try {
|
||||
await this.getStoragePlugin().uploadAvatar(picture);
|
||||
this.logger.log(
|
||||
`Profile picture uploaded successfully to ${
|
||||
this.getStoragePlugin().id
|
||||
}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error while uploading profile picture to ${
|
||||
this.getStoragePlugin().id
|
||||
}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Save profile picture locally
|
||||
const dirPath = path.join(config.parameters.avatarDir, filename);
|
||||
try {
|
||||
await fs.promises.mkdir(config.parameters.avatarDir, {
|
||||
recursive: true,
|
||||
}); // Ensure the directory exists
|
||||
const dest = fs.createWriteStream(dirPath);
|
||||
res.body.pipe(dest);
|
||||
this.logger.debug(
|
||||
'Messenger Channel Handler : Profile picture fetched successfully',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Messenger Channel Handler : Error while creating directory',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
|
||||
* Otherwise, uploads files to the local directory.
|
||||
*
|
||||
* @param files - An array of files to upload.
|
||||
* @returns A promise that resolves to an array of uploaded attachments.
|
||||
*/
|
||||
async uploadFiles(files: { file: Express.Multer.File[] }) {
|
||||
if (this.getStoragePlugin()) {
|
||||
const dtos = await Promise.all(
|
||||
files.file.map((file) => {
|
||||
return this.getStoragePlugin().upload(file);
|
||||
}),
|
||||
);
|
||||
const uploadedFiles = await Promise.all(
|
||||
dtos.map((dto) => {
|
||||
return this.create(dto);
|
||||
}),
|
||||
);
|
||||
return uploadedFiles;
|
||||
} else {
|
||||
if (Array.isArray(files?.file)) {
|
||||
const uploadedFiles = await Promise.all(
|
||||
files?.file?.map(async ({ size, filename, mimetype }) => {
|
||||
return await this.create({
|
||||
size,
|
||||
type: mimetype,
|
||||
name: filename,
|
||||
channel: {},
|
||||
location: `/${filename}`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
return uploadedFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an attachment identified by the provided parameters.
|
||||
*
|
||||
* @param attachment - The attachment to download.
|
||||
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
|
||||
*/
|
||||
async download(attachment: Attachment) {
|
||||
if (this.getStoragePlugin()) {
|
||||
return await this.getStoragePlugin().download(attachment);
|
||||
} else {
|
||||
if (!fileExists(attachment.location)) {
|
||||
throw new NotFoundException('No file was found');
|
||||
}
|
||||
|
||||
const path = join(config.parameters.uploadDir, attachment.location);
|
||||
|
||||
const disposition = `attachment; filename="${encodeURIComponent(
|
||||
attachment.name,
|
||||
)}"`;
|
||||
|
||||
return getStreamableFile({
|
||||
path,
|
||||
options: {
|
||||
type: attachment.type,
|
||||
length: attachment.size,
|
||||
disposition,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
53
api/src/attachment/utilities/index.ts
Normal file
53
api/src/attachment/utilities/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 { createReadStream, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Logger, StreamableFile } from '@nestjs/common';
|
||||
import { StreamableFileOptions } from '@nestjs/common/file-stream/interfaces/streamable-options.interface';
|
||||
|
||||
import { config } from '@/config';
|
||||
|
||||
export const MIME_REGEX = /^[a-z-]+\/[0-9a-z\-.]+$/gm;
|
||||
|
||||
export const isMime = (type: string): boolean => {
|
||||
return MIME_REGEX.test(type);
|
||||
};
|
||||
|
||||
export const fileExists = (location: string): boolean => {
|
||||
// bypass test env
|
||||
if (config.env === 'test') {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const dirPath = config.parameters.uploadDir;
|
||||
const fileLocation = join(dirPath, location);
|
||||
return existsSync(fileLocation);
|
||||
} catch (e) {
|
||||
new Logger(`Attachment Model : Unable to locate file: ${location}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getStreamableFile = ({
|
||||
path,
|
||||
options,
|
||||
}: {
|
||||
path: string;
|
||||
options?: StreamableFileOptions;
|
||||
}) => {
|
||||
// bypass test env
|
||||
if (config.env === 'test') {
|
||||
return new StreamableFile(Buffer.from(''), options);
|
||||
}
|
||||
const fileReadStream = createReadStream(path);
|
||||
|
||||
return new StreamableFile(fileReadStream, options);
|
||||
};
|
||||
31
api/src/channel/channel.controller.ts
Normal file
31
api/src/channel/channel.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { ChannelService } from './channel.service';
|
||||
|
||||
@Controller('channel')
|
||||
export class ChannelController {
|
||||
constructor(private readonly channelService: ChannelService) {}
|
||||
|
||||
/**
|
||||
* Retrieves the list of channels.
|
||||
*
|
||||
* @returns An array of objects where each object represents a channel with a `name` property.
|
||||
*/
|
||||
@Get()
|
||||
getChannels(): { name: string }[] {
|
||||
return this.channelService.getAll().map((handler) => {
|
||||
return {
|
||||
name: handler.getChannel(),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
34
api/src/channel/channel.middleware.ts
Normal file
34
api/src/channel/channel.middleware.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
import { ChannelService } from './channel.service';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelMiddleware implements NestMiddleware {
|
||||
constructor(private readonly channelService: ChannelService) {}
|
||||
|
||||
async use(req: Request, res: Response, next: NextFunction) {
|
||||
// Iterate through channel handlers to execute a certain middleware if needed
|
||||
try {
|
||||
const [_, path, channelName] = req.path.split('/');
|
||||
if (path === 'webhook' && channelName) {
|
||||
const channel = this.channelService.getChannelHandler(channelName);
|
||||
if (channel) {
|
||||
return await channel.middleware(req, res, next);
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(new Error(`Unable to execute middleware on route ${req.path}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
40
api/src/channel/channel.module.ts
Normal file
40
api/src/channel/channel.module.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||
import { InjectDynamicProviders } from 'nestjs-dynamic-providers';
|
||||
|
||||
import { AttachmentModule } from '@/attachment/attachment.module';
|
||||
import { ChatModule } from '@/chat/chat.module';
|
||||
import { CmsModule } from '@/cms/cms.module';
|
||||
import { NlpModule } from '@/nlp/nlp.module';
|
||||
|
||||
import { ChannelController } from './channel.controller';
|
||||
import { ChannelMiddleware } from './channel.middleware';
|
||||
import { ChannelService } from './channel.service';
|
||||
import { WebhookController } from './webhook.controller';
|
||||
|
||||
export interface ChannelModuleOptions {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
@InjectDynamicProviders('dist/**/*.channel.js')
|
||||
@Module({
|
||||
controllers: [WebhookController, ChannelController],
|
||||
providers: [ChannelService],
|
||||
exports: [ChannelService],
|
||||
imports: [NlpModule, ChatModule, AttachmentModule, CmsModule],
|
||||
})
|
||||
export class ChannelModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(ChannelMiddleware)
|
||||
.forRoutes({ path: 'webhook/*', method: RequestMethod.ALL });
|
||||
}
|
||||
}
|
||||
170
api/src/channel/channel.service.ts
Normal file
170
api/src/channel/channel.service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { SubscriberService } from '@/chat/services/subscriber.service';
|
||||
import { LIVE_CHAT_TEST_CHANNEL_NAME } from '@/extensions/channels/live-chat-tester/settings';
|
||||
import { OFFLINE_CHANNEL_NAME } from '@/extensions/channels/offline/settings';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
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 { SocketRequest } from '@/websocket/utils/socket-request';
|
||||
import { SocketResponse } from '@/websocket/utils/socket-response';
|
||||
|
||||
import ChannelHandler from './lib/Handler';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelService {
|
||||
private registry: Map<string, ChannelHandler> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly logger: LoggerService,
|
||||
private readonly subscriberService: SubscriberService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registers a channel with a specific handler.
|
||||
*
|
||||
* @param name - The name of the channel to be registered.
|
||||
* @param channel - The channel handler associated with the channel name.
|
||||
* @typeParam C The channel handler's type that extends `ChannelHandler`.
|
||||
*/
|
||||
public setChannel<C extends ChannelHandler>(name: string, channel: C) {
|
||||
this.registry.set(name, channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all registered channel handlers.
|
||||
*
|
||||
* @returns An array of all channel handlers currently registered.
|
||||
*/
|
||||
public getAll() {
|
||||
return Array.from(this.registry.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns the channel handler associated with the specified channel name.
|
||||
*
|
||||
* @param name - The name of the channel to find.
|
||||
* @returns The channel handler associated with the specified name, or undefined if the channel is not found.
|
||||
*/
|
||||
public findChannel(name: string) {
|
||||
return this.getAll().find((c) => {
|
||||
return c.getChannel() === name;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the appropriate channel handler based on the channel name.
|
||||
*
|
||||
* @param channelName - The name of the channel (messenger, offline, ...).
|
||||
* @returns The handler for the specified channel.
|
||||
*/
|
||||
public getChannelHandler<C extends ChannelHandler>(name: string): C {
|
||||
const handler = this.registry.get(name);
|
||||
if (!handler) {
|
||||
throw new Error(`Channel ${name} not found`);
|
||||
}
|
||||
return handler as C;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a request for a specific channel.
|
||||
*
|
||||
* @param channel - The channel for which the request is being handled.
|
||||
* @param req - The HTTP request object.
|
||||
* @param res - The HTTP response object.
|
||||
* @returns A promise that resolves when the handler has processed the request.
|
||||
*/
|
||||
async handle(channel: string, req: Request, res: Response): Promise<void> {
|
||||
const handler = this.getChannelHandler(channel);
|
||||
handler.handle(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a websocket request for the offline channel.
|
||||
*
|
||||
* @param req - The websocket request object.
|
||||
* @param res - The websocket response object.
|
||||
*/
|
||||
@SocketGet('/webhook/offline/')
|
||||
@SocketPost('/webhook/offline/')
|
||||
handleWebsocketForOffline(
|
||||
@SocketReq() req: SocketRequest,
|
||||
@SocketRes() res: SocketResponse,
|
||||
) {
|
||||
this.logger.log('Channel notification (Offline Socket) : ', req.method);
|
||||
const handler = this.getChannelHandler(OFFLINE_CHANNEL_NAME);
|
||||
return handler.handle(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a websocket request for the live chat tester channel.
|
||||
* It considers the user as a subscriber.
|
||||
*
|
||||
* @param req - The websocket request object.
|
||||
* @param res - The websocket response object.
|
||||
*/
|
||||
@SocketGet('/webhook/live-chat-tester/')
|
||||
@SocketPost('/webhook/live-chat-tester/')
|
||||
async handleWebsocketForLiveChatTester(
|
||||
@SocketReq() req: SocketRequest,
|
||||
@SocketRes() res: SocketResponse,
|
||||
) {
|
||||
this.logger.log(
|
||||
'Channel notification (Live Chat Tester Socket) : ',
|
||||
req.method,
|
||||
);
|
||||
|
||||
if (!req.session?.passport?.user?.id) {
|
||||
throw new UnauthorizedException(
|
||||
'Only authenticated users are allowed to use this channel',
|
||||
);
|
||||
}
|
||||
|
||||
// Create test subscriber for the current user
|
||||
const testSubscriber = await this.subscriberService.findOneOrCreate(
|
||||
{
|
||||
foreign_id: req.session.passport.user.id,
|
||||
},
|
||||
{
|
||||
id: req.session.passport.user.id,
|
||||
foreign_id: req.session.passport.user.id,
|
||||
first_name: req.session.passport.user.first_name,
|
||||
last_name: req.session.passport.user.last_name,
|
||||
locale: '',
|
||||
language: '',
|
||||
gender: '',
|
||||
country: '',
|
||||
labels: [],
|
||||
channel: {
|
||||
name: LIVE_CHAT_TEST_CHANNEL_NAME,
|
||||
isSocket: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Update session (end user is both a user + subscriber)
|
||||
req.session.offline = {
|
||||
profile: testSubscriber,
|
||||
isSocket: true,
|
||||
messageQueue: [],
|
||||
polling: false,
|
||||
};
|
||||
|
||||
const handler = this.getChannelHandler(LIVE_CHAT_TEST_CHANNEL_NAME);
|
||||
return handler.handle(req, res);
|
||||
}
|
||||
}
|
||||
392
api/src/channel/lib/EventWrapper.ts
Normal file
392
api/src/channel/lib/EventWrapper.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* 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 { Subscriber } from '@/chat/schemas/subscriber.schema';
|
||||
import {
|
||||
AttachmentForeignKey,
|
||||
AttachmentPayload,
|
||||
} from '@/chat/schemas/types/attachment';
|
||||
import {
|
||||
IncomingMessageType,
|
||||
StdEventType,
|
||||
StdIncomingMessage,
|
||||
} from '@/chat/schemas/types/message';
|
||||
import { Payload } from '@/chat/schemas/types/quick-reply';
|
||||
import { Nlp } from '@/nlp/lib/types';
|
||||
|
||||
import ChannelHandler from './Handler';
|
||||
|
||||
export interface ChannelEvent {}
|
||||
|
||||
export default abstract class EventWrapper<
|
||||
A,
|
||||
E,
|
||||
C extends ChannelHandler = ChannelHandler,
|
||||
> {
|
||||
_adapter: A = {} as A;
|
||||
|
||||
_handler: C;
|
||||
|
||||
_profile!: Subscriber;
|
||||
|
||||
_nlp!: Nlp.ParseEntities;
|
||||
|
||||
/**
|
||||
* Constructor : Class used to wrap any channel's event in order
|
||||
* to provide a unified interface for accessing data by the chatbot.
|
||||
*
|
||||
* Any method declared in this class should be extended and overridden in any given channel's
|
||||
* event wrapper if needed.
|
||||
* @param handler - The channel's handler
|
||||
* @param event - The message event received
|
||||
* @param channelData - Channel's specific data
|
||||
*/
|
||||
constructor(handler: C, event: E, channelData: any = {}) {
|
||||
this._handler = handler;
|
||||
this._init(event);
|
||||
this.set('channelData', channelData);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
handler: this._handler.getChannel(),
|
||||
channelData: this.getChannelData(),
|
||||
sender: this.getSender(),
|
||||
recipient: this.getRecipientForeignId(),
|
||||
eventType: this.getEventType(),
|
||||
messageType: this.getMessageType(),
|
||||
payload: this.getPayload(),
|
||||
message: this.getMessage(),
|
||||
attachments: this.getAttachments(),
|
||||
deliveredMessages: this.getDeliveredMessages(),
|
||||
watermark: this.getWatermark(),
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the parent constructor, it defines `_adapter` which should store :
|
||||
*
|
||||
* - `_adapter.eventType` : The type of event received
|
||||
*
|
||||
*- `_adapter.messageType` : The type of message when the event is a message.
|
||||
*
|
||||
*- `_adapter.raw` : Sets a typed object of the event raw data
|
||||
* @param event - The message event received from a given channel
|
||||
*/
|
||||
abstract _init(event: E): void;
|
||||
|
||||
/**
|
||||
* Retrieves the current channel handler.
|
||||
*
|
||||
* @returns The current instance of the channel handler.
|
||||
*/
|
||||
getHandler(): ChannelHandler {
|
||||
return this._handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves channel data.
|
||||
*
|
||||
* @returns Returns any channel related data.
|
||||
*/
|
||||
getChannelData(): any {
|
||||
return this.get('channelData', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message id.
|
||||
* @returns the message id.
|
||||
*/
|
||||
abstract getId(): string;
|
||||
|
||||
/**
|
||||
* Sets an event attribute value
|
||||
*
|
||||
* @param attr - Event attribute name
|
||||
* @param value - The value to set for the specified attribute.
|
||||
*/
|
||||
set(attr: string, value: any) {
|
||||
(this._adapter as any).raw[attr] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an event attribute value, default value if it does exist
|
||||
*
|
||||
* @param attr - Event attribute name
|
||||
* @param otherwise - Default value if attribute does not exist
|
||||
*
|
||||
* @returns The value of the specified attribute or the default value.
|
||||
*/
|
||||
get(attr: string, otherwise: any): any {
|
||||
return attr in (this._adapter as any).raw
|
||||
? ((this._adapter as any).raw as any)[attr]
|
||||
: otherwise || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns attached NLP parse results
|
||||
*
|
||||
* @returns The parsed NLP entities, or null if not available.
|
||||
*/
|
||||
getNLP(): Nlp.ParseEntities | null {
|
||||
return this._nlp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the NLP object to the event
|
||||
*
|
||||
* @param nlp - NLP parse results
|
||||
*/
|
||||
setNLP(nlp: Nlp.ParseEntities) {
|
||||
this._nlp = nlp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns event sender/profile id (channel's id)
|
||||
*
|
||||
* @returns sender/profile id
|
||||
*/
|
||||
abstract getSenderForeignId(): string;
|
||||
|
||||
/**
|
||||
* Returns event sender data
|
||||
*
|
||||
* @returns event sender data
|
||||
*/
|
||||
getSender(): Subscriber {
|
||||
return this._profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets event sender data
|
||||
*
|
||||
* @param profile - Sender data
|
||||
*/
|
||||
setSender(profile: Subscriber) {
|
||||
this._profile = profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns event recipient id
|
||||
*
|
||||
* @returns event recipient id
|
||||
*/
|
||||
abstract getRecipientForeignId(): string;
|
||||
|
||||
/**
|
||||
* Returns the type of event received (message, delivery, read, ...)
|
||||
*
|
||||
* @returns The type of event received (message, delivery, read, ...)
|
||||
*/
|
||||
abstract getEventType(): StdEventType;
|
||||
|
||||
/**
|
||||
* Identifies the type of the message received
|
||||
*
|
||||
* @return The type of message
|
||||
*/
|
||||
abstract getMessageType(): IncomingMessageType;
|
||||
|
||||
/**
|
||||
* Return payload whenever user clicks on a button/quick_reply or sends an attachment, false otherwise
|
||||
*
|
||||
* @returns The payload content
|
||||
*/
|
||||
abstract getPayload(): Payload | string | undefined;
|
||||
|
||||
/**
|
||||
* Returns the message in a standardized format
|
||||
*
|
||||
* @returns The received message
|
||||
*/
|
||||
abstract getMessage(): any;
|
||||
|
||||
/**
|
||||
* Return the text message received
|
||||
*
|
||||
* @returns Received text message
|
||||
*/
|
||||
getText(): string {
|
||||
const message = this.getMessage();
|
||||
if ('text' in message) {
|
||||
return message.text;
|
||||
} else if ('serialized_text' in message) {
|
||||
return message.serialized_text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of received attachments
|
||||
*
|
||||
* @returns Received attachments message
|
||||
*/
|
||||
abstract getAttachments(): AttachmentPayload<AttachmentForeignKey>[];
|
||||
|
||||
/**
|
||||
* Returns the list of delivered messages
|
||||
*
|
||||
* @returns Array of message ids
|
||||
*/
|
||||
abstract getDeliveredMessages(): string[];
|
||||
|
||||
/**
|
||||
* Returns the message's watermark
|
||||
*
|
||||
* @returns The message's watermark
|
||||
*/
|
||||
abstract getWatermark(): number;
|
||||
}
|
||||
|
||||
type GenericEvent = { senderId: string; messageId: string };
|
||||
type GenericEventAdapter = {
|
||||
eventType: StdEventType.unknown;
|
||||
messageType: IncomingMessageType.unknown;
|
||||
raw: GenericEvent;
|
||||
};
|
||||
|
||||
export class GenericEventWrapper extends EventWrapper<
|
||||
GenericEventAdapter,
|
||||
GenericEvent
|
||||
> {
|
||||
/**
|
||||
* Constructor : channel's event wrapper
|
||||
*
|
||||
* @param handler - The channel's handler
|
||||
* @param event - The message event received
|
||||
*/
|
||||
constructor(handler: ChannelHandler, event: GenericEvent) {
|
||||
super(handler, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the parent constructor, it defines :
|
||||
*
|
||||
* - The type of event received
|
||||
*
|
||||
* - The type of message when the event is a message.
|
||||
*
|
||||
* - Sets a typed raw object of the event data
|
||||
* @param event - The message event received
|
||||
|
||||
*/
|
||||
_init(event: GenericEvent): void {
|
||||
this._adapter.eventType = StdEventType.unknown;
|
||||
this._adapter.messageType = IncomingMessageType.unknown;
|
||||
this._adapter.raw = event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns channel related data
|
||||
*
|
||||
* @returns An object representing the channel specific data
|
||||
*/
|
||||
getChannelData(): any {
|
||||
return this.get('channelData', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message id
|
||||
*
|
||||
* @returns The message id
|
||||
*/
|
||||
getId(): string {
|
||||
if (this._adapter.raw.messageId) {
|
||||
return this._adapter.raw.messageId;
|
||||
}
|
||||
throw new Error('The message id `mid` is missing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns event sender id
|
||||
*
|
||||
* @returns event sender id
|
||||
*/
|
||||
getSenderForeignId(): string {
|
||||
if (this._adapter.raw.senderId) {
|
||||
return this._adapter.raw.senderId;
|
||||
}
|
||||
throw new Error('The sender id is missing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns event recipient id (channel's id)
|
||||
*
|
||||
* @returns Returns event recipient id
|
||||
*/
|
||||
getRecipientForeignId(): string {
|
||||
throw new Error('The recipient id is missing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of event received
|
||||
*
|
||||
* @returns The type of event received
|
||||
*/
|
||||
getEventType(): StdEventType {
|
||||
return this._adapter.eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out and returns the type of the event received from the channel
|
||||
*
|
||||
* @returns The type of message
|
||||
*/
|
||||
getMessageType(): IncomingMessageType {
|
||||
return this._adapter.messageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns payload whenever user clicks on a button/quick_reply or sends an attachment
|
||||
*
|
||||
* @returns The payload content
|
||||
*/
|
||||
getPayload(): Payload | string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a standard message format that can be stored in DB
|
||||
*
|
||||
* @returns Received message in standard format
|
||||
*/
|
||||
getMessage(): StdIncomingMessage {
|
||||
throw new Error('Unknown incoming message type');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns A list of received attachments
|
||||
* @deprecated - This method is deprecated
|
||||
*/
|
||||
getAttachments(): AttachmentPayload<AttachmentForeignKey>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the delivered messages ids
|
||||
*
|
||||
* @returns return delivered messages ids
|
||||
*/
|
||||
getDeliveredMessages(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message's watermark (timestamp or equivalent).
|
||||
*
|
||||
* @returns The message's watermark
|
||||
*/
|
||||
getWatermark() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
211
api/src/channel/lib/Handler.ts
Normal file
211
api/src/channel/lib/Handler.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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 { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto';
|
||||
import {
|
||||
StdOutgoingEnvelope,
|
||||
StdOutgoingMessage,
|
||||
} from '@/chat/schemas/types/message';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import BaseNlpHelper from '@/nlp/lib/BaseNlpHelper';
|
||||
import { NlpService } from '@/nlp/services/nlp.service';
|
||||
import { SettingCreateDto } from '@/setting/dto/setting.dto';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { SocketRequest } from '@/websocket/utils/socket-request';
|
||||
import { SocketResponse } from '@/websocket/utils/socket-response';
|
||||
|
||||
import EventWrapper from './EventWrapper';
|
||||
import { ChannelService } from '../channel.service';
|
||||
|
||||
@Injectable()
|
||||
export default abstract class ChannelHandler {
|
||||
protected settings: SettingCreateDto[] = [];
|
||||
|
||||
protected NLP: BaseNlpHelper;
|
||||
|
||||
constructor(
|
||||
protected readonly settingService: SettingService,
|
||||
private readonly channelService: ChannelService,
|
||||
protected readonly nlpService: NlpService,
|
||||
protected readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.channelService.setChannel(this.getChannel(), this);
|
||||
this.setup();
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await this.settingService.seedIfNotExist(this.getChannel(), this.settings);
|
||||
const nlp = this.nlpService.getNLP();
|
||||
this.setNLP(nlp);
|
||||
this.init();
|
||||
}
|
||||
|
||||
setNLP(nlp: BaseNlpHelper) {
|
||||
this.NLP = nlp;
|
||||
}
|
||||
|
||||
getNLP() {
|
||||
return this.NLP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the channel specific settings
|
||||
*/
|
||||
async getSettings<S>() {
|
||||
const settings = await this.settingService.getSettings();
|
||||
return settings[this.getChannel()] as S;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the channel's name
|
||||
* @returns {String}
|
||||
*/
|
||||
abstract getChannel(): string;
|
||||
|
||||
/**
|
||||
* Perform any initialization needed
|
||||
* @returns
|
||||
|
||||
*/
|
||||
abstract init(): void;
|
||||
|
||||
/**
|
||||
* @param {module:Controller.req} req
|
||||
* @param {module:Controller.res} res
|
||||
* Process incoming channel data via POST/GET methods
|
||||
*/
|
||||
abstract handle(
|
||||
req: Request | SocketRequest,
|
||||
res: Response | SocketResponse,
|
||||
): any;
|
||||
|
||||
/**
|
||||
* Format a text message that will be sent to the channel
|
||||
* @param message - A text to be sent to the end user
|
||||
* @param options - might contain additional settings
|
||||
* @returns {Object} - A text message in the channel specific format
|
||||
|
||||
*/
|
||||
abstract _textFormat(message: StdOutgoingMessage, options?: any): any;
|
||||
|
||||
/**
|
||||
* @param message - A text + quick replies to be sent to the end user
|
||||
* @param options - might contain additional settings
|
||||
* @returns {Object} - A quick replies message in the channel specific format
|
||||
* Format a text + quick replies message that can be sent to the channel
|
||||
*/
|
||||
abstract _quickRepliesFormat(message: StdOutgoingMessage, options?: any): any;
|
||||
|
||||
/**
|
||||
* @param message - A text + buttons to be sent to the end user
|
||||
* @param options - Might contain additional settings
|
||||
* @returns {Object} - A buttons message in the format required by the channel
|
||||
* From raw buttons, construct a channel understable message containing those buttons
|
||||
*/
|
||||
abstract _buttonsFormat(
|
||||
message: StdOutgoingMessage,
|
||||
options?: any,
|
||||
...args: any
|
||||
): any;
|
||||
|
||||
/**
|
||||
* @param message - An attachment + quick replies to be sent to the end user
|
||||
* @param options - Might contain additional settings
|
||||
* @returns {Object} - An attachment message in the format required by the channel
|
||||
* Format an attachment + quick replies message that can be sent to the channel
|
||||
*/
|
||||
abstract _attachmentFormat(message: StdOutgoingMessage, options?: any): any;
|
||||
|
||||
/**
|
||||
* @param data - A list of data items to be sent to the end user
|
||||
* @param options - Might contain additional settings
|
||||
* @returns {Object[]} - An array of element objects
|
||||
* Format a collection of items to be sent to the channel in carousel/list format
|
||||
*/
|
||||
abstract _formatElements(data: any[], options: any, ...args: any): any[];
|
||||
|
||||
/**
|
||||
* Format a list of elements
|
||||
* @param message - Contains elements to be sent to the end user
|
||||
* @param options - Might contain additional settings
|
||||
* @returns {Object} - A ready to be sent list template message in the format required by the channel
|
||||
|
||||
*/
|
||||
abstract _listFormat(
|
||||
message: StdOutgoingMessage,
|
||||
options: any,
|
||||
...args: any
|
||||
): any;
|
||||
|
||||
/**
|
||||
* Format a carousel message
|
||||
* @param message - Contains elements to be sent to the end user
|
||||
* @param options - Might contain additional settings
|
||||
* @returns {Object} - A carousel ready to be sent in the format required by the channel
|
||||
|
||||
*/
|
||||
abstract _carouselFormat(
|
||||
message: StdOutgoingMessage,
|
||||
options: any,
|
||||
...args: any
|
||||
): any;
|
||||
|
||||
/**
|
||||
* Send a channel Message to the end user
|
||||
* @param event - Incoming event/message being responded to
|
||||
* @param envelope - The message to be sent {format, message}
|
||||
* @param options - Might contain additional settings
|
||||
* @param context - Contextual data
|
||||
* @returns {Promise} - The channel's response, otherwise an error
|
||||
|
||||
*/
|
||||
abstract sendMessage(
|
||||
event: EventWrapper<any, any>,
|
||||
envelope: StdOutgoingEnvelope,
|
||||
options: any,
|
||||
context: any,
|
||||
): Promise<{ mid: string }>;
|
||||
|
||||
/**
|
||||
* Fetch the end user profile data
|
||||
* @param event - The message event received
|
||||
* @returns {Promise<Subscriber>} - The channel's response, otherwise an error
|
||||
|
||||
*/
|
||||
abstract getUserData(
|
||||
event: EventWrapper<any, any>,
|
||||
): Promise<SubscriberCreateDto>;
|
||||
|
||||
/**
|
||||
* @param _attachment - The attachment that needs to be uploaded to the channel
|
||||
* @returns {Promise<Attachment>}
|
||||
* Uploads an attachment to the channel as some require file to be uploaded so
|
||||
* that they could be used in messaging (dimelo, twitter, ...)
|
||||
*/
|
||||
async uploadAttachment(_attachment: Attachment): Promise<Attachment> {
|
||||
return _attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom channel middleware
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
async middleware(_req: Request, _res: Response, next: NextFunction) {
|
||||
// Do nothing, override in channel
|
||||
next();
|
||||
}
|
||||
}
|
||||
14
api/src/channel/lib/__test__/base.mock.ts
Normal file
14
api/src/channel/lib/__test__/base.mock.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const modelInstance = {
|
||||
id: '1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
174
api/src/channel/lib/__test__/common.mock.ts
Normal file
174
api/src/channel/lib/__test__/common.mock.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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 { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
import { WithUrl } from '@/chat/schemas/types/attachment';
|
||||
import { ButtonType } from '@/chat/schemas/types/button';
|
||||
import {
|
||||
FileType,
|
||||
OutgoingMessageFormat,
|
||||
StdOutgoingAttachmentMessage,
|
||||
StdOutgoingButtonsMessage,
|
||||
StdOutgoingListMessage,
|
||||
StdOutgoingQuickRepliesMessage,
|
||||
StdOutgoingTextMessage,
|
||||
} from '@/chat/schemas/types/message';
|
||||
import { QuickReplyType } from '@/chat/schemas/types/quick-reply';
|
||||
|
||||
export const textMessage: StdOutgoingTextMessage = {
|
||||
text: 'Hello World',
|
||||
};
|
||||
|
||||
export const quickRepliesMessage: StdOutgoingQuickRepliesMessage = {
|
||||
text: 'Choose one option',
|
||||
quickReplies: [
|
||||
{
|
||||
content_type: QuickReplyType.text,
|
||||
title: 'First option',
|
||||
payload: 'first_option',
|
||||
},
|
||||
{
|
||||
content_type: QuickReplyType.text,
|
||||
title: 'Second option',
|
||||
payload: 'second_option',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const buttonsMessage: StdOutgoingButtonsMessage = {
|
||||
text: 'Hit one of these buttons :',
|
||||
buttons: [
|
||||
{
|
||||
type: ButtonType.postback,
|
||||
title: 'First button',
|
||||
payload: 'first_button',
|
||||
},
|
||||
{
|
||||
type: ButtonType.web_url,
|
||||
title: 'Second button',
|
||||
url: 'http://button.com',
|
||||
messenger_extensions: true,
|
||||
webview_height_ratio: 'compact',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const urlButtonsMessage: StdOutgoingButtonsMessage = {
|
||||
text: 'Hit one of these buttons :',
|
||||
buttons: [
|
||||
{
|
||||
type: ButtonType.web_url,
|
||||
title: 'First button',
|
||||
url: 'http://button1.com',
|
||||
messenger_extensions: true,
|
||||
webview_height_ratio: 'compact',
|
||||
},
|
||||
{
|
||||
type: ButtonType.web_url,
|
||||
title: 'Second button',
|
||||
url: 'http://button2.com',
|
||||
messenger_extensions: true,
|
||||
webview_height_ratio: 'compact',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: '1',
|
||||
name: 'attachment.jpg',
|
||||
type: 'image/jpeg',
|
||||
size: 3539,
|
||||
location: '39991e51-55c6-4a26-9176-b6ba04f180dc.jpg',
|
||||
channel: {
|
||||
['dimelo']: {
|
||||
id: 'attachment-id-dimelo',
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const attachmentWithUrl: WithUrl<Attachment> = {
|
||||
...attachment,
|
||||
url: 'http://localhost:4000/attachment/download/1/attachment.jpg',
|
||||
};
|
||||
|
||||
export const contentMessage: StdOutgoingListMessage = {
|
||||
options: {
|
||||
display: OutgoingMessageFormat.list,
|
||||
fields: {
|
||||
title: 'title',
|
||||
subtitle: 'desc',
|
||||
image_url: 'thumbnail',
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: ButtonType.postback,
|
||||
title: 'More',
|
||||
payload: '',
|
||||
},
|
||||
],
|
||||
limit: 2,
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
id: '1',
|
||||
entity: 'rank',
|
||||
title: 'First',
|
||||
// @ts-expect-error Necessary workaround
|
||||
desc: 'About being first',
|
||||
thumbnail: {
|
||||
payload: attachmentWithUrl,
|
||||
},
|
||||
getPayload() {
|
||||
return this.title;
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
entity: 'rank',
|
||||
title: 'Second',
|
||||
// @ts-expect-error Necessary workaround
|
||||
desc: 'About being second',
|
||||
thumbnail: {
|
||||
payload: attachmentWithUrl,
|
||||
},
|
||||
getPayload() {
|
||||
return this.title;
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: true,
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
total: 3,
|
||||
skip: 0,
|
||||
limit: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const attachmentMessage: StdOutgoingAttachmentMessage<
|
||||
WithUrl<Attachment>
|
||||
> = {
|
||||
attachment: {
|
||||
type: FileType.image,
|
||||
payload: attachmentWithUrl,
|
||||
},
|
||||
quickReplies: [
|
||||
{
|
||||
content_type: QuickReplyType.text,
|
||||
title: 'Next >',
|
||||
payload: 'NEXT',
|
||||
},
|
||||
],
|
||||
};
|
||||
63
api/src/channel/lib/__test__/label.mock.ts
Normal file
63
api/src/channel/lib/__test__/label.mock.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 { Label } from '@/chat/schemas/label.schema';
|
||||
|
||||
import { modelInstance } from './base.mock';
|
||||
|
||||
const baseLabel: Label = {
|
||||
...modelInstance,
|
||||
title: '',
|
||||
name: '',
|
||||
label_id: {
|
||||
messenger: '',
|
||||
offline: '',
|
||||
dimelo: '',
|
||||
twitter: '',
|
||||
},
|
||||
description: '',
|
||||
builtin: false,
|
||||
};
|
||||
|
||||
export const labelMock: Label = {
|
||||
...baseLabel,
|
||||
title: 'Label',
|
||||
name: 'label',
|
||||
label_id: {
|
||||
messenger: 'none',
|
||||
offline: 'none',
|
||||
dimelo: 'none',
|
||||
twitter: 'none',
|
||||
},
|
||||
};
|
||||
|
||||
export const customerLabelsMock: Label[] = [
|
||||
{
|
||||
...baseLabel,
|
||||
title: 'Client',
|
||||
name: 'client',
|
||||
label_id: {
|
||||
messenger: 'none',
|
||||
offline: 'none',
|
||||
dimelo: 'none',
|
||||
twitter: 'none',
|
||||
},
|
||||
},
|
||||
{
|
||||
...baseLabel,
|
||||
title: 'Professional',
|
||||
name: 'profressional',
|
||||
label_id: {
|
||||
messenger: 'none',
|
||||
offline: 'none',
|
||||
dimelo: 'none',
|
||||
twitter: 'none',
|
||||
},
|
||||
},
|
||||
];
|
||||
44
api/src/channel/lib/__test__/subscriber.mock.ts
Normal file
44
api/src/channel/lib/__test__/subscriber.mock.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 { Subscriber } from '@/chat/schemas/subscriber.schema';
|
||||
|
||||
import { modelInstance } from './base.mock';
|
||||
import { customerLabelsMock } from './label.mock';
|
||||
|
||||
export const subscriberInstance: Subscriber = {
|
||||
foreign_id: 'foreign-id-for-jhon-doe',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
language: 'fr',
|
||||
locale: 'fr_FR',
|
||||
gender: 'male',
|
||||
timezone: -1,
|
||||
country: 'TN',
|
||||
assignedTo: null,
|
||||
assignedAt: null,
|
||||
lastvisit: new Date(),
|
||||
retainedFrom: new Date(),
|
||||
channel: {
|
||||
name: 'offline',
|
||||
},
|
||||
labels: [],
|
||||
...modelInstance,
|
||||
};
|
||||
|
||||
export const subscriberWithoutLabels: Subscriber = {
|
||||
...subscriberInstance,
|
||||
labels: [],
|
||||
};
|
||||
|
||||
export const subscriberWithLabels: Subscriber = {
|
||||
...subscriberWithoutLabels,
|
||||
labels: customerLabelsMock.map(({ id }) => id),
|
||||
assignedTo: null,
|
||||
};
|
||||
70
api/src/channel/webhook.controller.ts
Normal file
70
api/src/channel/webhook.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 { Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
||||
import { Request, Response } from 'express'; // Import the Express request and response types
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { Roles } from '@/utils/decorators/roles.decorator';
|
||||
|
||||
import { ChannelService } from './channel.service';
|
||||
|
||||
@Controller('webhook')
|
||||
export class WebhookController {
|
||||
constructor(
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handles GET requests of a specific channel.
|
||||
* This endpoint is accessible to public access (messaging platforms).
|
||||
* It logs the request method and the channel name, then delegates the request
|
||||
* to the `channelService` for further handling.
|
||||
*
|
||||
* @param channel - The name of the channel for which the request is being sent.
|
||||
* @param req - The HTTP request object.
|
||||
* @param res - The HTTP response object.
|
||||
*
|
||||
* @returns A promise that resolves with the result of the `channelService.handle` method.
|
||||
*/
|
||||
@Roles('public')
|
||||
@Get(':channel')
|
||||
async handleGet(
|
||||
@Param('channel') channel: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<any> {
|
||||
this.logger.log('Channel notification : ', req.method, channel);
|
||||
return await this.channelService.handle(channel, req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles POST requests for a specific channel.
|
||||
* This endpoint is accessible to public access (messaging platforms).
|
||||
* It logs the request method and the channel name, then delegates the request
|
||||
* to the `channelService` for further handling.
|
||||
*
|
||||
* @param channel - The name of the channel for which the notification is being sent.
|
||||
* @param req - The HTTP request object.
|
||||
* @param res - The HTTP response object.
|
||||
*
|
||||
* @returns A promise that resolves with the result of the `channelService.handle` method.
|
||||
*/
|
||||
@Roles('public')
|
||||
@Post(':channel')
|
||||
async handlePost(
|
||||
@Param('channel') channel: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.log('Channel notification : ', req.method, channel);
|
||||
return await this.channelService.handle(channel, req, res);
|
||||
}
|
||||
}
|
||||
111
api/src/chat/chat.module.ts
Normal file
111
api/src/chat/chat.module.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 { forwardRef, Module } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
|
||||
import { AttachmentModule } from '@/attachment/attachment.module';
|
||||
import { ChannelModule } from '@/channel/channel.module';
|
||||
import { CmsModule } from '@/cms/cms.module';
|
||||
import { NlpModule } from '@/nlp/nlp.module';
|
||||
import { UserModule } from '@/user/user.module';
|
||||
|
||||
import { BlockController } from './controllers/block.controller';
|
||||
import { CategoryController } from './controllers/category.controller';
|
||||
import { ContextVarController } from './controllers/context-var.controller';
|
||||
import { LabelController } from './controllers/label.controller';
|
||||
import { MessageController } from './controllers/message.controller';
|
||||
import { SubscriberController } from './controllers/subscriber.controller';
|
||||
import { TranslationController } from './controllers/translation.controller';
|
||||
import { BlockRepository } from './repositories/block.repository';
|
||||
import { CategoryRepository } from './repositories/category.repository';
|
||||
import { ContextVarRepository } from './repositories/context-var.repository';
|
||||
import { ConversationRepository } from './repositories/conversation.repository';
|
||||
import { LabelRepository } from './repositories/label.repository';
|
||||
import { MessageRepository } from './repositories/message.repository';
|
||||
import { SubscriberRepository } from './repositories/subscriber.repository';
|
||||
import { TranslationRepository } from './repositories/translation.repository';
|
||||
import { BlockModel } from './schemas/block.schema';
|
||||
import { CategoryModel } from './schemas/category.schema';
|
||||
import { ContextVarModel } from './schemas/context-var.schema';
|
||||
import { ConversationModel } from './schemas/conversation.schema';
|
||||
import { LabelModel } from './schemas/label.schema';
|
||||
import { MessageModel } from './schemas/message.schema';
|
||||
import { SubscriberModel } from './schemas/subscriber.schema';
|
||||
import { TranslationModel } from './schemas/translation.schema';
|
||||
import { CategorySeeder } from './seeds/category.seed';
|
||||
import { ContextVarSeeder } from './seeds/context-var.seed';
|
||||
import { TranslationSeeder } from './seeds/translation.seed';
|
||||
import { BlockService } from './services/block.service';
|
||||
import { BotService } from './services/bot.service';
|
||||
import { CategoryService } from './services/category.service';
|
||||
import { ChatService } from './services/chat.service';
|
||||
import { ContextVarService } from './services/context-var.service';
|
||||
import { ConversationService } from './services/conversation.service';
|
||||
import { LabelService } from './services/label.service';
|
||||
import { MessageService } from './services/message.service';
|
||||
import { SubscriberService } from './services/subscriber.service';
|
||||
import { TranslationService } from './services/translation.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
CategoryModel,
|
||||
ContextVarModel,
|
||||
LabelModel,
|
||||
BlockModel,
|
||||
MessageModel,
|
||||
SubscriberModel,
|
||||
TranslationModel,
|
||||
ConversationModel,
|
||||
SubscriberModel,
|
||||
]),
|
||||
forwardRef(() => ChannelModule),
|
||||
CmsModule,
|
||||
AttachmentModule,
|
||||
NlpModule,
|
||||
EventEmitter2,
|
||||
UserModule,
|
||||
],
|
||||
controllers: [
|
||||
CategoryController,
|
||||
ContextVarController,
|
||||
LabelController,
|
||||
BlockController,
|
||||
MessageController,
|
||||
SubscriberController,
|
||||
TranslationController,
|
||||
],
|
||||
providers: [
|
||||
CategoryRepository,
|
||||
ContextVarRepository,
|
||||
LabelRepository,
|
||||
BlockRepository,
|
||||
MessageRepository,
|
||||
SubscriberRepository,
|
||||
TranslationRepository,
|
||||
ConversationRepository,
|
||||
CategoryService,
|
||||
ContextVarService,
|
||||
LabelService,
|
||||
BlockService,
|
||||
MessageService,
|
||||
SubscriberService,
|
||||
TranslationService,
|
||||
CategorySeeder,
|
||||
ContextVarSeeder,
|
||||
ConversationService,
|
||||
ChatService,
|
||||
BotService,
|
||||
TranslationSeeder,
|
||||
],
|
||||
exports: [SubscriberService, MessageService, LabelService, BlockService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
325
api/src/chat/controllers/block.controller.spec.ts
Normal file
325
api/src/chat/controllers/block.controller.spec.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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 { NotFoundException } from '@nestjs/common';
|
||||
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 { ContentRepository } from '@/cms/repositories/content.repository';
|
||||
import { ContentModel } from '@/cms/schemas/content.schema';
|
||||
import { ContentService } from '@/cms/services/content.service';
|
||||
import { ExtendedI18nService } from '@/extended-i18n.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { PermissionRepository } from '@/user/repositories/permission.repository';
|
||||
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 { UserModel } from '@/user/schemas/user.schema';
|
||||
import { PermissionService } from '@/user/services/permission.service';
|
||||
import { RoleService } from '@/user/services/role.service';
|
||||
import { UserService } from '@/user/services/user.service';
|
||||
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
|
||||
import {
|
||||
blockFixtures,
|
||||
installBlockFixtures,
|
||||
} from '@/utils/test/fixtures/block';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { CategoryModel, Category } from './../schemas/category.schema';
|
||||
import { BlockController } from './block.controller';
|
||||
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
|
||||
import { BlockRepository } from '../repositories/block.repository';
|
||||
import { CategoryRepository } from '../repositories/category.repository';
|
||||
import { LabelRepository } from '../repositories/label.repository';
|
||||
import { BlockModel, Block } from '../schemas/block.schema';
|
||||
import { LabelModel } from '../schemas/label.schema';
|
||||
import { BlockService } from '../services/block.service';
|
||||
import { CategoryService } from '../services/category.service';
|
||||
import { LabelService } from '../services/label.service';
|
||||
|
||||
describe('BlockController', () => {
|
||||
let blockController: BlockController;
|
||||
let blockService: BlockService;
|
||||
let categoryService: CategoryService;
|
||||
let category: Category;
|
||||
let block: Block;
|
||||
let blockToDelete: Block;
|
||||
let hasNextBlocks: Block;
|
||||
let hasPreviousBlocks: Block;
|
||||
const FIELDS_TO_POPULATE = [
|
||||
'trigger_labels',
|
||||
'assign_labels',
|
||||
'nextBlocks',
|
||||
'attachedBlock',
|
||||
'category',
|
||||
'previousBlocks',
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [BlockController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installBlockFixtures),
|
||||
MongooseModule.forFeature([
|
||||
BlockModel,
|
||||
LabelModel,
|
||||
CategoryModel,
|
||||
ContentModel,
|
||||
AttachmentModel,
|
||||
UserModel,
|
||||
RoleModel,
|
||||
PermissionModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
BlockRepository,
|
||||
LabelRepository,
|
||||
CategoryRepository,
|
||||
ContentRepository,
|
||||
AttachmentRepository,
|
||||
UserRepository,
|
||||
RoleRepository,
|
||||
PermissionRepository,
|
||||
BlockService,
|
||||
LabelService,
|
||||
CategoryService,
|
||||
ContentService,
|
||||
AttachmentService,
|
||||
UserService,
|
||||
RoleService,
|
||||
PermissionService,
|
||||
PluginService,
|
||||
LoggerService,
|
||||
{
|
||||
provide: ExtendedI18nService,
|
||||
useValue: {
|
||||
t: jest.fn().mockImplementation((t) => t),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingService,
|
||||
useValue: {
|
||||
getConfig: jest.fn(() => ({
|
||||
chatbot: { lang: { default: 'fr' } },
|
||||
})),
|
||||
getSettings: jest.fn(() => ({})),
|
||||
},
|
||||
},
|
||||
EventEmitter2,
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: {
|
||||
del: jest.fn(),
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
blockController = module.get<BlockController>(BlockController);
|
||||
blockService = module.get<BlockService>(BlockService);
|
||||
categoryService = module.get<CategoryService>(CategoryService);
|
||||
category = await categoryService.findOne({ label: 'default' });
|
||||
block = await blockService.findOne({ name: 'first' });
|
||||
blockToDelete = await blockService.findOne({ name: 'buttons' });
|
||||
hasNextBlocks = await blockService.findOne({
|
||||
name: 'hasNextBlocks',
|
||||
});
|
||||
hasPreviousBlocks = await blockService.findOne({
|
||||
name: 'hasPreviousBlocks',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('find', () => {
|
||||
it('should find all blocks', async () => {
|
||||
jest.spyOn(blockService, 'find');
|
||||
const result = await blockController.find([], {});
|
||||
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
|
||||
...blockFixture,
|
||||
category: category.id,
|
||||
nextBlocks:
|
||||
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks.id] : [],
|
||||
}));
|
||||
|
||||
expect(blockService.find).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(blocksWithCategory, [
|
||||
...IGNORED_TEST_FIELDS,
|
||||
'attachedToBlock',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find all blocks, and foreach block populate the corresponding category and previousBlocks', async () => {
|
||||
jest.spyOn(blockService, 'findAndPopulate');
|
||||
const category = await categoryService.findOne({ label: 'default' });
|
||||
const result = await blockController.find(FIELDS_TO_POPULATE, {});
|
||||
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
|
||||
...blockFixture,
|
||||
category,
|
||||
previousBlocks:
|
||||
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
|
||||
nextBlocks:
|
||||
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
|
||||
}));
|
||||
|
||||
expect(blockService.findAndPopulate).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(blocksWithCategory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find one block by id', async () => {
|
||||
jest.spyOn(blockService, 'findOne');
|
||||
const result = await blockController.findOne(hasNextBlocks.id, []);
|
||||
expect(blockService.findOne).toHaveBeenCalledWith(hasNextBlocks.id);
|
||||
expect(result).toEqualPayload(
|
||||
{
|
||||
...blockFixtures.find(({ name }) => name === hasNextBlocks.name),
|
||||
category: category.id,
|
||||
nextBlocks: [hasPreviousBlocks.id],
|
||||
},
|
||||
[...IGNORED_TEST_FIELDS, 'attachedToBlock'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should find one block by id, and populate its category and previousBlocks', async () => {
|
||||
jest.spyOn(blockService, 'findOneAndPopulate');
|
||||
const result = await blockController.findOne(
|
||||
hasPreviousBlocks.id,
|
||||
FIELDS_TO_POPULATE,
|
||||
);
|
||||
expect(blockService.findOneAndPopulate).toHaveBeenCalledWith(
|
||||
hasPreviousBlocks.id,
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
...blockFixtures.find(({ name }) => name === 'hasPreviousBlocks'),
|
||||
category,
|
||||
previousBlocks: [hasNextBlocks],
|
||||
});
|
||||
});
|
||||
|
||||
it('should find one block by id, and populate its category and an empty previousBlocks', async () => {
|
||||
jest.spyOn(blockService, 'findOneAndPopulate');
|
||||
block = await blockService.findOne({ name: 'attachment' });
|
||||
const result = await blockController.findOne(
|
||||
block.id,
|
||||
FIELDS_TO_POPULATE,
|
||||
);
|
||||
expect(blockService.findOneAndPopulate).toHaveBeenCalledWith(block.id);
|
||||
expect(result).toEqualPayload({
|
||||
...blockFixtures.find(({ name }) => name === 'attachment'),
|
||||
category,
|
||||
previousBlocks: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should return created block', async () => {
|
||||
jest.spyOn(blockService, 'create');
|
||||
const mockedBlockCreateDto: BlockCreateDto = {
|
||||
name: 'block with nextBlocks',
|
||||
nextBlocks: [hasNextBlocks.id],
|
||||
patterns: ['Hi'],
|
||||
trigger_labels: [],
|
||||
assign_labels: [],
|
||||
trigger_channels: [],
|
||||
category: category.id,
|
||||
options: {
|
||||
typing: 0,
|
||||
fallback: {
|
||||
active: false,
|
||||
max_attempts: 1,
|
||||
message: [],
|
||||
},
|
||||
},
|
||||
message: ['Hi back !'],
|
||||
starts_conversation: false,
|
||||
capture_vars: [],
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
};
|
||||
const result = await blockController.create(mockedBlockCreateDto);
|
||||
|
||||
expect(blockService.create).toHaveBeenCalledWith(mockedBlockCreateDto);
|
||||
expect(result).toEqualPayload(
|
||||
{
|
||||
...mockedBlockCreateDto,
|
||||
},
|
||||
[...IGNORED_TEST_FIELDS, 'nextBlocks', 'builtin'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne', () => {
|
||||
it('should delete block', async () => {
|
||||
jest.spyOn(blockService, 'deleteOne');
|
||||
const result = await blockController.deleteOne(blockToDelete.id);
|
||||
|
||||
expect(blockService.deleteOne).toHaveBeenCalledWith(blockToDelete.id);
|
||||
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when attempting to delete a block by id', async () => {
|
||||
await expect(blockController.deleteOne(blockToDelete.id)).rejects.toThrow(
|
||||
new NotFoundException(`Block with ID ${blockToDelete.id} not found`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOne', () => {
|
||||
it('should return updated block', async () => {
|
||||
jest.spyOn(blockService, 'updateOne');
|
||||
const updateBlock: BlockUpdateDto = {
|
||||
name: 'modified block name',
|
||||
};
|
||||
const result = await blockController.updateOne(block.id, updateBlock);
|
||||
|
||||
expect(blockService.updateOne).toHaveBeenCalledWith(
|
||||
block.id,
|
||||
updateBlock,
|
||||
);
|
||||
expect(result).toEqualPayload(
|
||||
{
|
||||
...blockFixtures.find(({ name }) => name === block.name),
|
||||
category: category.id,
|
||||
...updateBlock,
|
||||
},
|
||||
[...IGNORED_TEST_FIELDS, 'attachedToBlock'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when attempting to update a block by id', async () => {
|
||||
const updateBlock: BlockUpdateDto = {
|
||||
name: 'attempt to modify block name',
|
||||
};
|
||||
|
||||
await expect(
|
||||
blockController.updateOne(blockToDelete.id, updateBlock),
|
||||
).rejects.toThrow(
|
||||
new NotFoundException(`Block with ID ${blockToDelete.id} not found`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
300
api/src/chat/controllers/block.controller.ts
Normal file
300
api/src/chat/controllers/block.controller.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* 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 {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseBlockPlugin } from '@/plugins/base-block-plugin';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { PluginType } from '@/plugins/types';
|
||||
import { UserService } from '@/user/services/user.service';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
import { DeleteResult } from '@/utils/generics/base-repository';
|
||||
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
|
||||
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
|
||||
import { Block, BlockFull, BlockStub } from '../schemas/block.schema';
|
||||
import { BlockService } from '../services/block.service';
|
||||
import { CategoryService } from '../services/category.service';
|
||||
import { LabelService } from '../services/label.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('Block')
|
||||
export class BlockController extends BaseController<Block, BlockStub> {
|
||||
constructor(
|
||||
private readonly blockService: BlockService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly categoryService: CategoryService,
|
||||
private readonly labelService: LabelService,
|
||||
private readonly userService: UserService,
|
||||
private pluginsService: PluginService<BaseBlockPlugin>,
|
||||
) {
|
||||
super(blockService);
|
||||
}
|
||||
/**
|
||||
* Finds blocks based on the provided query parameters.
|
||||
* @param populate - An array of fields to populate in the returned blocks.
|
||||
* @param filters - Query filters to apply to the block search.
|
||||
* @returns A Promise that resolves to an array of found blocks.
|
||||
*/
|
||||
|
||||
@Get()
|
||||
async find(
|
||||
@Query(PopulatePipe)
|
||||
populate: string[],
|
||||
@Query(new SearchFilterPipe<Block>({ allowedFields: ['category'] }))
|
||||
filters: TFilterQuery<Block>,
|
||||
): Promise<Block[] | BlockFull[]> {
|
||||
return this.canPopulate(populate, [
|
||||
'trigger_labels',
|
||||
'assign_labels',
|
||||
'nextBlocks',
|
||||
'attachedBlock',
|
||||
'category',
|
||||
'previousBlocks',
|
||||
'attachedToBlock',
|
||||
])
|
||||
? await this.blockService.findAndPopulate(filters)
|
||||
: await this.blockService.find(filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a custom block settings for a specific plugin.
|
||||
*
|
||||
* @param pluginId - The name of the plugin for which settings are to be retrieved.
|
||||
*
|
||||
* @returns An array containing the settings of the specified plugin.
|
||||
*/
|
||||
@Get('customBlocks/settings')
|
||||
findSettings(@Query('plugin') pluginId: string) {
|
||||
try {
|
||||
if (!pluginId) {
|
||||
throw new BadRequestException(
|
||||
'Plugin id must be supplied as a query param',
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = this.pluginsService.getPlugin(PluginType.block, pluginId);
|
||||
|
||||
if (!plugin) {
|
||||
throw new NotFoundException('Plugin Not Found');
|
||||
}
|
||||
|
||||
return plugin.settings;
|
||||
} catch (e) {
|
||||
this.logger.error('Unable to fetch plugin settings', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all custom blocks (plugins) along with their associated block template.
|
||||
*
|
||||
* @returns An array containing available custom blocks.
|
||||
*/
|
||||
@Get('customBlocks')
|
||||
findAll() {
|
||||
try {
|
||||
const plugins = this.pluginsService
|
||||
.getAllByType(PluginType.block)
|
||||
.map((p) => ({
|
||||
title: p.title,
|
||||
name: p.id,
|
||||
template: {
|
||||
...p.template,
|
||||
message: {
|
||||
plugin: p.id,
|
||||
args: p.settings.reduce(
|
||||
(acc, setting) => {
|
||||
acc[setting.id] = setting.value;
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: any },
|
||||
),
|
||||
},
|
||||
},
|
||||
effects: typeof p.effects === 'object' ? Object.keys(p.effects) : [],
|
||||
}));
|
||||
return plugins;
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// @TODO : remove once old frontend is abandoned
|
||||
/**
|
||||
* Retrieves the effects of all plugins that have effects defined.
|
||||
*
|
||||
* @returns An array containing objects representing the effects of plugins.
|
||||
*/
|
||||
@Get('effects')
|
||||
findEffects(): {
|
||||
name: string;
|
||||
title: any;
|
||||
}[] {
|
||||
try {
|
||||
const plugins = this.pluginsService.getAllByType(PluginType.block);
|
||||
const effects = Object.keys(plugins)
|
||||
.filter(
|
||||
(plugin) =>
|
||||
typeof plugins[plugin].effects === 'object' &&
|
||||
Object.keys(plugins[plugin].effects).length > 0,
|
||||
)
|
||||
.map((plugin) => ({
|
||||
name: plugin,
|
||||
title: plugins[plugin].title,
|
||||
}));
|
||||
|
||||
return effects;
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single block by its ID.
|
||||
*
|
||||
* @param id - The ID of the block to retrieve.
|
||||
* @param populate - An array of fields to populate in the retrieved block.
|
||||
* @returns A Promise that resolves to the retrieved block.
|
||||
*/
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@Query(PopulatePipe)
|
||||
populate: string[],
|
||||
): Promise<Block | BlockFull> {
|
||||
const doc = this.canPopulate(populate, [
|
||||
'trigger_labels',
|
||||
'assign_labels',
|
||||
'nextBlocks',
|
||||
'attachedBlock',
|
||||
'category',
|
||||
'previousBlocks',
|
||||
'attachedToBlock',
|
||||
])
|
||||
? await this.blockService.findOneAndPopulate(id)
|
||||
: await this.blockService.findOne(id);
|
||||
if (!doc) {
|
||||
this.logger.warn(`Unable to find Block by id ${id}`);
|
||||
throw new NotFoundException(`Block with ID ${id} not found`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new block.
|
||||
*
|
||||
* @param block - The data of the block to be created.
|
||||
* @returns A Promise that resolves to the created block.
|
||||
*/
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Post()
|
||||
async create(@Body() block: BlockCreateDto): Promise<Block> {
|
||||
this.validate({
|
||||
dto: block,
|
||||
allowedIds: {
|
||||
category: (await this.categoryService.findOne(block.category))?.id,
|
||||
attachedBlock: (await this.blockService.findOne(block.attachedBlock))
|
||||
?.id,
|
||||
nextBlocks: (
|
||||
await this.blockService.find({
|
||||
_id: {
|
||||
$in: block.nextBlocks,
|
||||
},
|
||||
})
|
||||
).map(({ id }) => id),
|
||||
assign_labels: (
|
||||
await this.labelService.find({
|
||||
_id: {
|
||||
$in: block.assign_labels,
|
||||
},
|
||||
})
|
||||
).map(({ id }) => id),
|
||||
trigger_labels: (
|
||||
await this.labelService.find({
|
||||
_id: {
|
||||
$in: block.trigger_labels,
|
||||
},
|
||||
})
|
||||
).map(({ id }) => id),
|
||||
},
|
||||
});
|
||||
// TODO: the validate function doesn't support nested objects, we need to refactor it to support nested objects
|
||||
if (block.options?.assignTo) {
|
||||
const user = await this.userService.findOne(block.options.assignTo);
|
||||
if (!user) {
|
||||
throw new BadRequestException(
|
||||
`options.assignTo with ID ${block.options.assignTo} not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return await this.blockService.create(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a specific block by ID.
|
||||
*
|
||||
* @param id - The ID of the block to update.
|
||||
* @param blockUpdate - The data to update the block with.
|
||||
* @returns A Promise that resolves to the updated block if successful.
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Patch(':id')
|
||||
async updateOne(
|
||||
@Param('id') id: string,
|
||||
@Body() blockUpdate: BlockUpdateDto,
|
||||
): Promise<Block> {
|
||||
const result = await this.blockService.updateOne(id, blockUpdate);
|
||||
if (!result) {
|
||||
this.logger.warn(`Unable to update Block by id ${id}`);
|
||||
throw new NotFoundException(`Block with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a specific block by ID.
|
||||
*
|
||||
* @param id - The ID of the block to delete.
|
||||
* @returns A Promise that resolves to the deletion result.
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
|
||||
const result = await this.blockService.deleteOne(id);
|
||||
if (result.deletedCount === 0) {
|
||||
this.logger.warn(`Unable to delete Block by id ${id}`);
|
||||
throw new NotFoundException(`Block with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
210
api/src/chat/controllers/category.contoller.spec.ts
Normal file
210
api/src/chat/controllers/category.contoller.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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 { NotFoundException } from '@nestjs/common';
|
||||
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 { ContentRepository } from '@/cms/repositories/content.repository';
|
||||
import { ContentModel } from '@/cms/schemas/content.schema';
|
||||
import { ContentService } from '@/cms/services/content.service';
|
||||
import { ExtendedI18nService } from '@/extended-i18n.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import {
|
||||
categoryFixtures,
|
||||
installCategoryFixtures,
|
||||
} from '@/utils/test/fixtures/category';
|
||||
import { getPageQuery } from '@/utils/test/pagination';
|
||||
import { sortRowsBy } from '@/utils/test/sort';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { Category, CategoryModel } from './../schemas/category.schema';
|
||||
import { CategoryController } from './category.controller';
|
||||
import { CategoryCreateDto, CategoryUpdateDto } from '../dto/category.dto';
|
||||
import { BlockRepository } from '../repositories/block.repository';
|
||||
import { CategoryRepository } from '../repositories/category.repository';
|
||||
import { BlockModel } from '../schemas/block.schema';
|
||||
import { LabelModel } from '../schemas/label.schema';
|
||||
import { BlockService } from '../services/block.service';
|
||||
import { CategoryService } from '../services/category.service';
|
||||
|
||||
describe('CategoryController', () => {
|
||||
let categoryController: CategoryController;
|
||||
let categoryService: CategoryService;
|
||||
let category: Category;
|
||||
let categoryToDelete: Category;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [CategoryController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installCategoryFixtures),
|
||||
MongooseModule.forFeature([
|
||||
BlockModel,
|
||||
LabelModel,
|
||||
CategoryModel,
|
||||
ContentModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
BlockRepository,
|
||||
CategoryRepository,
|
||||
ContentRepository,
|
||||
AttachmentRepository,
|
||||
BlockService,
|
||||
CategoryService,
|
||||
ContentService,
|
||||
AttachmentService,
|
||||
{
|
||||
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(() => ({})),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BlockService,
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
EventEmitter2,
|
||||
],
|
||||
}).compile();
|
||||
categoryService = module.get<CategoryService>(CategoryService);
|
||||
categoryController = module.get<CategoryController>(CategoryController);
|
||||
category = await categoryService.findOne({ label: 'test category 1' });
|
||||
categoryToDelete = await categoryService.findOne({
|
||||
label: 'test category 2',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findPage', () => {
|
||||
it('should return an array of categories', async () => {
|
||||
const pageQuery = getPageQuery<Category>();
|
||||
const result = await categoryController.findPage(pageQuery, {});
|
||||
|
||||
expect(result).toEqualPayload(categoryFixtures.sort(sortRowsBy));
|
||||
});
|
||||
});
|
||||
|
||||
describe('count', () => {
|
||||
it('should count categories', async () => {
|
||||
jest.spyOn(categoryService, 'count');
|
||||
const result = await categoryController.filterCount();
|
||||
|
||||
expect(categoryService.count).toHaveBeenCalled();
|
||||
expect(result).toEqual({ count: categoryFixtures.length });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return the existing category', async () => {
|
||||
jest.spyOn(categoryService, 'findOne');
|
||||
const category = await categoryService.findOne({
|
||||
label: 'test category 1',
|
||||
});
|
||||
const result = await categoryController.findOne(category.id);
|
||||
|
||||
expect(categoryService.findOne).toHaveBeenCalledWith(category.id);
|
||||
expect(result).toEqualPayload({
|
||||
...categoryFixtures.find(({ label }) => label === 'test category 1'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should return created category', async () => {
|
||||
jest.spyOn(categoryService, 'create');
|
||||
const categoryCreateDto: CategoryCreateDto = {
|
||||
label: 'categoryLabel2',
|
||||
builtin: true,
|
||||
zoom: 100,
|
||||
offset: [0, 0],
|
||||
};
|
||||
const result = await categoryController.create(categoryCreateDto);
|
||||
|
||||
expect(categoryService.create).toHaveBeenCalledWith(categoryCreateDto);
|
||||
expect(result).toEqualPayload(categoryCreateDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne', () => {
|
||||
it('should delete a category by id', async () => {
|
||||
jest.spyOn(categoryService, 'deleteOne');
|
||||
const result = await categoryController.deleteOne(categoryToDelete.id);
|
||||
expect(categoryService.deleteOne).toHaveBeenCalledWith(
|
||||
categoryToDelete.id,
|
||||
);
|
||||
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
|
||||
});
|
||||
|
||||
it('should throw a NotFoundException when attempting to delete a category by id', async () => {
|
||||
jest.spyOn(categoryService, 'deleteOne');
|
||||
|
||||
const result = categoryController.deleteOne(categoryToDelete.id);
|
||||
expect(categoryService.deleteOne).toHaveBeenCalledWith(
|
||||
categoryToDelete.id,
|
||||
);
|
||||
await expect(result).rejects.toThrow(
|
||||
new NotFoundException(
|
||||
`Category with ID ${categoryToDelete.id} not found`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOne', () => {
|
||||
const categoryUpdateDto: CategoryUpdateDto = {
|
||||
builtin: false,
|
||||
};
|
||||
it('should return updated category', async () => {
|
||||
jest.spyOn(categoryService, 'updateOne');
|
||||
const result = await categoryController.updateOne(
|
||||
category.id,
|
||||
categoryUpdateDto,
|
||||
);
|
||||
|
||||
expect(categoryService.updateOne).toHaveBeenCalledWith(
|
||||
category.id,
|
||||
categoryUpdateDto,
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
...categoryFixtures.find(({ label }) => label === 'test category 1'),
|
||||
...categoryUpdateDto,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
143
api/src/chat/controllers/category.controller.ts
Normal file
143
api/src/chat/controllers/category.controller.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
import { DeleteResult } from '@/utils/generics/base-repository';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
|
||||
import { CategoryCreateDto, CategoryUpdateDto } from '../dto/category.dto';
|
||||
import { Category } from '../schemas/category.schema';
|
||||
import { BlockService } from '../services/block.service';
|
||||
import { CategoryService } from '../services/category.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('category')
|
||||
export class CategoryController extends BaseController<Category> {
|
||||
constructor(
|
||||
private readonly categoryService: CategoryService,
|
||||
private readonly blockService: BlockService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(categoryService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of categories based on provided filters and pagination settings.
|
||||
* @param pageQuery - The pagination settings.
|
||||
* @param filters - The filters to apply to the category search.
|
||||
* @returns A Promise that resolves to a paginated list of categories.
|
||||
*/
|
||||
@Get()
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Category>,
|
||||
@Query(new SearchFilterPipe<Category>({ allowedFields: ['label'] }))
|
||||
filters: TFilterQuery<Category>,
|
||||
) {
|
||||
return await this.categoryService.findPage(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the filtered number of categories.
|
||||
* @returns A promise that resolves to an object representing the filtered number of categories.
|
||||
*/
|
||||
@Get('count')
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<Category>({
|
||||
allowedFields: ['label'],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<Category>,
|
||||
) {
|
||||
return await this.count(filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a category by its ID.
|
||||
* @param id - The ID of the category to find.
|
||||
* @returns A Promise that resolves to the found category.
|
||||
*/
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<Category> {
|
||||
const doc = await this.categoryService.findOne(id);
|
||||
if (!doc) {
|
||||
this.logger.warn(`Unable to find Category by id ${id}`);
|
||||
throw new NotFoundException(`Category with ID ${id} not found`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new category.
|
||||
* @param category - The data of the category to be created.
|
||||
* @returns A Promise that resolves to the created category.
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Post()
|
||||
async create(@Body() category: CategoryCreateDto): Promise<Category> {
|
||||
return await this.categoryService.create(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing category.
|
||||
* @param id - The ID of the category to be updated.
|
||||
* @param categoryUpdate - The updated data for the category.
|
||||
* @returns A Promise that resolves to the updated category.
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Patch(':id')
|
||||
async updateOne(
|
||||
@Param('id') id: string,
|
||||
@Body() categoryUpdate: CategoryUpdateDto,
|
||||
): Promise<Category> {
|
||||
const result = await this.categoryService.updateOne(id, categoryUpdate);
|
||||
if (!result) {
|
||||
this.logger.warn(`Unable to update Category by id ${id}`);
|
||||
throw new NotFoundException(`Category with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a category by its ID.
|
||||
* @param id - The ID of the category to be deleted.
|
||||
* @returns A Promise that resolves to the deletion result.
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
|
||||
const result = await this.categoryService.deleteOne(id);
|
||||
if (result.deletedCount === 0) {
|
||||
this.logger.warn(`Unable to delete Category by id ${id}`);
|
||||
throw new NotFoundException(`Category with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
174
api/src/chat/controllers/context-var.controller.spec.ts
Normal file
174
api/src/chat/controllers/context-var.controller.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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 { NotFoundException } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import {
|
||||
contextVarFixtures,
|
||||
installContextVarFixtures,
|
||||
} from '@/utils/test/fixtures/contextvar';
|
||||
import { getPageQuery } from '@/utils/test/pagination';
|
||||
import { sortRowsBy } from '@/utils/test/sort';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { ContextVarController } from './context-var.controller';
|
||||
import {
|
||||
ContextVarCreateDto,
|
||||
ContextVarUpdateDto,
|
||||
} from '../dto/context-var.dto';
|
||||
import { ContextVarRepository } from '../repositories/context-var.repository';
|
||||
import { ContextVarModel, ContextVar } from '../schemas/context-var.schema';
|
||||
import { ContextVarService } from '../services/context-var.service';
|
||||
|
||||
describe('ContextVarController', () => {
|
||||
let contextVarController: ContextVarController;
|
||||
let contextVarService: ContextVarService;
|
||||
let contextVar: ContextVar;
|
||||
let contextVarToDelete: ContextVar;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [ContextVarController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installContextVarFixtures),
|
||||
MongooseModule.forFeature([ContextVarModel]),
|
||||
],
|
||||
providers: [LoggerService, ContextVarService, ContextVarRepository],
|
||||
}).compile();
|
||||
contextVarController =
|
||||
module.get<ContextVarController>(ContextVarController);
|
||||
contextVarService = module.get<ContextVarService>(ContextVarService);
|
||||
contextVar = await contextVarService.findOne({
|
||||
label: 'test context var 1',
|
||||
});
|
||||
contextVarToDelete = await contextVarService.findOne({
|
||||
label: 'test context var 2',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('count', () => {
|
||||
it('should count the contextVars', async () => {
|
||||
jest.spyOn(contextVarService, 'count');
|
||||
const result = await contextVarController.filterCount();
|
||||
|
||||
expect(contextVarService.count).toHaveBeenCalled();
|
||||
expect(result).toEqual({ count: contextVarFixtures.length });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPage', () => {
|
||||
it('should return an array of contextVars', async () => {
|
||||
const pageQuery = getPageQuery<ContextVar>();
|
||||
jest.spyOn(contextVarService, 'findPage');
|
||||
const result = await contextVarController.findPage(pageQuery, {});
|
||||
|
||||
expect(contextVarService.findPage).toHaveBeenCalledWith({}, pageQuery);
|
||||
expect(result).toEqualPayload(contextVarFixtures.sort(sortRowsBy));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return the existing contextVar', async () => {
|
||||
jest.spyOn(contextVarService, 'findOne');
|
||||
const result = await contextVarController.findOne(contextVar.id);
|
||||
|
||||
expect(contextVarService.findOne).toHaveBeenCalledWith(contextVar.id);
|
||||
expect(result).toEqualPayload(
|
||||
contextVarFixtures.find(({ label }) => label === contextVar.label),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should return created contextVar', async () => {
|
||||
jest.spyOn(contextVarService, 'create');
|
||||
const contextVarCreateDto: ContextVarCreateDto = {
|
||||
label: 'contextVarLabel2',
|
||||
name: 'test_add',
|
||||
};
|
||||
const result = await contextVarController.create(contextVarCreateDto);
|
||||
|
||||
expect(contextVarService.create).toHaveBeenCalledWith(
|
||||
contextVarCreateDto,
|
||||
);
|
||||
expect(result).toEqualPayload(contextVarCreateDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne', () => {
|
||||
it('should delete a contextVar by id', async () => {
|
||||
jest.spyOn(contextVarService, 'deleteOne');
|
||||
const result = await contextVarController.deleteOne(
|
||||
contextVarToDelete.id,
|
||||
);
|
||||
|
||||
expect(contextVarService.deleteOne).toHaveBeenCalledWith(
|
||||
contextVarToDelete.id,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
acknowledged: true,
|
||||
deletedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a NotFoundException when attempting to delete a contextVar by id', async () => {
|
||||
await expect(
|
||||
contextVarController.deleteOne(contextVarToDelete.id),
|
||||
).rejects.toThrow(
|
||||
new NotFoundException(
|
||||
`ContextVar with ID ${contextVarToDelete.id} not found`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOne', () => {
|
||||
const contextVarUpdatedDto: ContextVarUpdateDto = {
|
||||
name: 'updated_context_var_name',
|
||||
};
|
||||
it('should return updated contextVar', async () => {
|
||||
jest.spyOn(contextVarService, 'updateOne');
|
||||
const result = await contextVarController.updateOne(
|
||||
contextVar.id,
|
||||
contextVarUpdatedDto,
|
||||
);
|
||||
|
||||
expect(contextVarService.updateOne).toHaveBeenCalledWith(
|
||||
contextVar.id,
|
||||
contextVarUpdatedDto,
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
...contextVarFixtures.find(({ label }) => label === contextVar.label),
|
||||
...contextVarUpdatedDto,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a NotFoundException when attempting to update an non existing contextVar by id', async () => {
|
||||
await expect(
|
||||
contextVarController.updateOne(
|
||||
contextVarToDelete.id,
|
||||
contextVarUpdatedDto,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new NotFoundException(
|
||||
`ContextVar with ID ${contextVarToDelete.id} not found`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
148
api/src/chat/controllers/context-var.controller.ts
Normal file
148
api/src/chat/controllers/context-var.controller.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
import { DeleteResult } from '@/utils/generics/base-repository';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
|
||||
import {
|
||||
ContextVarCreateDto,
|
||||
ContextVarUpdateDto,
|
||||
} from '../dto/context-var.dto';
|
||||
import { ContextVar } from '../schemas/context-var.schema';
|
||||
import { ContextVarService } from '../services/context-var.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('contextvar')
|
||||
export class ContextVarController extends BaseController<ContextVar> {
|
||||
constructor(
|
||||
private readonly contextVarService: ContextVarService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(contextVarService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a page of contextVars based on specified filters and pagination parameters.
|
||||
* @param pageQuery - The pagination parameters.
|
||||
* @param filters - The filters to apply.
|
||||
* @returns A Promise that resolves to an array of contextVars.
|
||||
*/
|
||||
@Get()
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<ContextVar>,
|
||||
@Query(new SearchFilterPipe<ContextVar>({ allowedFields: ['label'] }))
|
||||
filters: TFilterQuery<ContextVar>,
|
||||
): Promise<ContextVar[]> {
|
||||
return await this.contextVarService.findPage(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the filtered number of contextVars.
|
||||
* @returns A promise that resolves to an object representing the filtered number of contextVars.
|
||||
*/
|
||||
@Get('count')
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<ContextVar>({
|
||||
allowedFields: ['label'],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<ContextVar>,
|
||||
) {
|
||||
return await this.count(filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a contextVar by its ID.
|
||||
* @param id - The ID of the contextVar to retrieve.
|
||||
* @returns A Promise that resolves to the retrieved contextVar.
|
||||
*/
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<ContextVar> {
|
||||
const doc = await this.contextVarService.findOne(id);
|
||||
if (!doc) {
|
||||
this.logger.warn(`Unable to find ContextVar by id ${id}`);
|
||||
throw new NotFoundException(`ContextVar with ID ${id} not found`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new contextVar.
|
||||
* @param contextVar - The data of the contextVar to create.
|
||||
* @returns A Promise that resolves to the created contextVar.
|
||||
*/
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Post()
|
||||
async create(@Body() contextVar: ContextVarCreateDto): Promise<ContextVar> {
|
||||
return await this.contextVarService.create(contextVar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing contextVar.
|
||||
* @param id - The ID of the contextVar to update.
|
||||
* @param contextVarUpdate - The updated data for the contextVar.
|
||||
* @returns A Promise that resolves to the updated contextVar.
|
||||
*/
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Patch(':id')
|
||||
async updateOne(
|
||||
@Param('id') id: string,
|
||||
@Body() contextVarUpdate: ContextVarUpdateDto,
|
||||
): Promise<ContextVar> {
|
||||
const result = await this.contextVarService.updateOne(id, contextVarUpdate);
|
||||
if (!result) {
|
||||
this.logger.warn(`Unable to update ContextVar by id ${id}`);
|
||||
throw new NotFoundException(`ContextVar with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a contextVar.
|
||||
* @param id - The ID of the contextVar to delete.
|
||||
* @returns A Promise that resolves to a DeleteResult.
|
||||
*/
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
|
||||
const result = await this.contextVarService.deleteOne(id);
|
||||
if (result.deletedCount === 0) {
|
||||
this.logger.warn(`Unable to delete ContextVar by id ${id}`);
|
||||
throw new NotFoundException(`ContextVar with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
237
api/src/chat/controllers/label.controller.spec.ts
Normal file
237
api/src/chat/controllers/label.controller.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* 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 { NotFoundException } from '@nestjs/common';
|
||||
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 { UserModel } from '@/user/schemas/user.schema';
|
||||
import { RoleService } from '@/user/services/role.service';
|
||||
import { UserService } from '@/user/services/user.service';
|
||||
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
|
||||
import { labelFixtures } from '@/utils/test/fixtures/label';
|
||||
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 { LabelController } from './label.controller';
|
||||
import { LabelCreateDto, LabelUpdateDto } from '../dto/label.dto';
|
||||
import { LabelRepository } from '../repositories/label.repository';
|
||||
import { SubscriberRepository } from '../repositories/subscriber.repository';
|
||||
import { Label, LabelModel } from '../schemas/label.schema';
|
||||
import { SubscriberModel } from '../schemas/subscriber.schema';
|
||||
import { LabelService } from '../services/label.service';
|
||||
import { SubscriberService } from '../services/subscriber.service';
|
||||
|
||||
describe('LabelController', () => {
|
||||
let labelController: LabelController;
|
||||
let labelService: LabelService;
|
||||
let label: Label;
|
||||
let labelToDelete: Label;
|
||||
let subscriberService: SubscriberService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [LabelController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installSubscriberFixtures),
|
||||
MongooseModule.forFeature([
|
||||
LabelModel,
|
||||
UserModel,
|
||||
RoleModel,
|
||||
PermissionModel,
|
||||
SubscriberModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
LabelController,
|
||||
LabelService,
|
||||
LabelRepository,
|
||||
UserService,
|
||||
UserRepository,
|
||||
RoleService,
|
||||
RoleRepository,
|
||||
SubscriberService,
|
||||
SubscriberRepository,
|
||||
EventEmitter2,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
],
|
||||
}).compile();
|
||||
labelService = module.get<LabelService>(LabelService);
|
||||
subscriberService = module.get<SubscriberService>(SubscriberService);
|
||||
labelController = module.get<LabelController>(LabelController);
|
||||
label = await labelService.findOne({ name: 'TEST_TITLE_1' });
|
||||
labelToDelete = await labelService.findOne({
|
||||
name: 'TEST_TITLE_2',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('count', () => {
|
||||
it('should count labels', async () => {
|
||||
jest.spyOn(labelService, 'count');
|
||||
const result = await labelController.filterCount();
|
||||
|
||||
expect(labelService.count).toHaveBeenCalled();
|
||||
expect(result).toEqual({ count: labelFixtures.length });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPage', () => {
|
||||
const pageQuery = getPageQuery<Label>();
|
||||
it('should find labels', async () => {
|
||||
jest.spyOn(labelService, 'findPage');
|
||||
const result = await labelController.findPage(pageQuery, [], {});
|
||||
const labelsWithBuiltin = labelFixtures.map((labelFixture) => ({
|
||||
...labelFixture,
|
||||
}));
|
||||
|
||||
expect(labelService.findPage).toHaveBeenCalledWith({}, pageQuery);
|
||||
expect(result).toEqualPayload(labelsWithBuiltin.sort(sortRowsBy), [
|
||||
...IGNORED_TEST_FIELDS,
|
||||
'nextBlocks',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find labels, and foreach label populate its corresponding users', async () => {
|
||||
jest.spyOn(labelService, 'findPageAndPopulate');
|
||||
const result = await labelController.findPage(pageQuery, ['users'], {});
|
||||
const allLabels = await labelService.findAll();
|
||||
const allSubscribers = await subscriberService.findAll();
|
||||
const labelsWithUsers = allLabels.map((label) => ({
|
||||
...label,
|
||||
users: allSubscribers,
|
||||
}));
|
||||
|
||||
expect(labelService.findPageAndPopulate).toHaveBeenCalledWith(
|
||||
{},
|
||||
pageQuery,
|
||||
);
|
||||
expect(result).toEqualPayload(labelsWithUsers.sort(sortRowsBy));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find one label by id', async () => {
|
||||
jest.spyOn(labelService, 'findOne');
|
||||
const result = await labelController.findOne(label.id, []);
|
||||
|
||||
expect(labelService.findOne).toHaveBeenCalledWith(label.id);
|
||||
expect(result).toEqualPayload(
|
||||
{
|
||||
...labelFixtures.find(({ name }) => name === label.name),
|
||||
},
|
||||
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should find one label by id, and populate its corresponding users', async () => {
|
||||
jest.spyOn(labelService, 'findOneAndPopulate');
|
||||
const result = await labelController.findOne(label.id, ['users']);
|
||||
const users = await subscriberService.findAll();
|
||||
|
||||
expect(labelService.findOneAndPopulate).toHaveBeenCalledWith(label.id);
|
||||
expect(result).toEqualPayload(
|
||||
{
|
||||
...labelFixtures.find(({ name }) => name === label.name),
|
||||
users,
|
||||
},
|
||||
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a label', async () => {
|
||||
jest.spyOn(labelService, 'create');
|
||||
const labelCreate: LabelCreateDto = {
|
||||
title: 'Label2',
|
||||
name: 'LABEL_2',
|
||||
label_id: {
|
||||
messenger: 'messenger',
|
||||
offline: 'offline',
|
||||
twitter: 'twitter',
|
||||
dimelo: 'dimelo',
|
||||
},
|
||||
description: 'LabelDescription2',
|
||||
};
|
||||
const result = await labelController.create(labelCreate);
|
||||
|
||||
expect(labelService.create).toHaveBeenCalledWith(labelCreate);
|
||||
expect(result).toEqualPayload({ ...labelCreate, builtin: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne', () => {
|
||||
it('should delete one label by id', async () => {
|
||||
jest.spyOn(labelService, 'deleteOne');
|
||||
const result = await labelController.deleteOne(labelToDelete.id);
|
||||
|
||||
expect(labelService.deleteOne).toHaveBeenCalledWith(labelToDelete.id);
|
||||
expect(result).toEqual({
|
||||
acknowledged: true,
|
||||
deletedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a NotFoundException when attempting to delete a non existing label by id', async () => {
|
||||
await expect(labelController.deleteOne(labelToDelete.id)).rejects.toThrow(
|
||||
new NotFoundException(`Label with ID ${labelToDelete.id} not found`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOne', () => {
|
||||
const labelUpdateDto: LabelUpdateDto = {
|
||||
description: 'test description 1',
|
||||
};
|
||||
it('should update a label by id', async () => {
|
||||
jest.spyOn(labelService, 'updateOne');
|
||||
const result = await labelController.updateOne(label.id, labelUpdateDto);
|
||||
|
||||
expect(labelService.updateOne).toHaveBeenCalledWith(
|
||||
label.id,
|
||||
labelUpdateDto,
|
||||
);
|
||||
expect(result).toEqualPayload(
|
||||
{
|
||||
...labelFixtures.find(({ name }) => name === label.name),
|
||||
...labelUpdateDto,
|
||||
},
|
||||
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a NotFoundException when attempting to update a non existing label by id', async () => {
|
||||
await expect(
|
||||
labelController.updateOne(labelToDelete.id, labelUpdateDto),
|
||||
).rejects.toThrow(
|
||||
new NotFoundException(`Label with ID ${labelToDelete.id} not found`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
124
api/src/chat/controllers/label.controller.ts
Normal file
124
api/src/chat/controllers/label.controller.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Patch,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
|
||||
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
|
||||
import { LabelCreateDto, LabelUpdateDto } from '../dto/label.dto';
|
||||
import { Label, LabelStub } from '../schemas/label.schema';
|
||||
import { LabelService } from '../services/label.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('label')
|
||||
export class LabelController extends BaseController<Label, LabelStub> {
|
||||
constructor(
|
||||
private readonly labelService: LabelService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(labelService);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Label>,
|
||||
@Query(PopulatePipe)
|
||||
populate: string[],
|
||||
@Query(new SearchFilterPipe<Label>({ allowedFields: ['name', 'title'] }))
|
||||
filters: TFilterQuery<Label>,
|
||||
) {
|
||||
return this.canPopulate(populate, ['users'])
|
||||
? await this.labelService.findPageAndPopulate(filters, pageQuery)
|
||||
: await this.labelService.findPage(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the filtered number of labels.
|
||||
* @returns A promise that resolves to an object representing the filtered number of labels.
|
||||
*/
|
||||
@Get('count')
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<Label>({
|
||||
allowedFields: ['name', 'title'],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<Label>,
|
||||
) {
|
||||
return await this.count(filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@Query(PopulatePipe)
|
||||
populate: string[],
|
||||
) {
|
||||
const doc = this.canPopulate(populate, ['users'])
|
||||
? await this.labelService.findOneAndPopulate(id)
|
||||
: await this.labelService.findOne(id);
|
||||
if (!doc) {
|
||||
this.logger.warn(`Unable to find Label by id ${id}`);
|
||||
throw new NotFoundException(`Label with ID ${id} not found`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Post()
|
||||
async create(@Body() label: LabelCreateDto) {
|
||||
return await this.labelService.create(label);
|
||||
}
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Patch(':id')
|
||||
async updateOne(
|
||||
@Param('id') id: string,
|
||||
@Body() labelUpdate: LabelUpdateDto,
|
||||
) {
|
||||
const result = await this.labelService.updateOne(id, labelUpdate);
|
||||
if (!result) {
|
||||
this.logger.warn(`Unable to update Label by id ${id}`);
|
||||
throw new NotFoundException(`Label with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async deleteOne(@Param('id') id: string) {
|
||||
const result = await this.labelService.deleteOne(id);
|
||||
if (result.deletedCount === 0) {
|
||||
this.logger.warn(`Unable to delete Label by id ${id}`);
|
||||
throw new NotFoundException(`Label with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
223
api/src/chat/controllers/message.controller.spec.ts
Normal file
223
api/src/chat/controllers/message.controller.spec.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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 { MenuRepository } from '@/cms/repositories/menu.repository';
|
||||
import { MenuModel } from '@/cms/schemas/menu.schema';
|
||||
import { MenuService } from '@/cms/services/menu.service';
|
||||
import { ExtendedI18nService } from '@/extended-i18n.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { NlpService } from '@/nlp/services/nlp.service';
|
||||
import { SettingService } from '@/setting/services/setting.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 {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { MessageController } from './message.controller';
|
||||
import { MessageRepository } from '../repositories/message.repository';
|
||||
import { SubscriberRepository } from '../repositories/subscriber.repository';
|
||||
import { Message, MessageModel } from '../schemas/message.schema';
|
||||
import { Subscriber, SubscriberModel } from '../schemas/subscriber.schema';
|
||||
import { MessageService } from '../services/message.service';
|
||||
import { SubscriberService } from '../services/subscriber.service';
|
||||
|
||||
describe('MessageController', () => {
|
||||
let messageController: MessageController;
|
||||
let messageService: MessageService;
|
||||
let subscriberService: SubscriberService;
|
||||
let userService: UserService;
|
||||
let sender: Subscriber;
|
||||
let recipient: Subscriber;
|
||||
let user: User;
|
||||
let message: Message;
|
||||
let allMessages: Message[];
|
||||
let allUsers: User[];
|
||||
let allSubscribers: Subscriber[];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [MessageController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installMessageFixtures),
|
||||
MongooseModule.forFeature([
|
||||
SubscriberModel,
|
||||
MessageModel,
|
||||
UserModel,
|
||||
RoleModel,
|
||||
PermissionModel,
|
||||
AttachmentModel,
|
||||
MenuModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
MessageController,
|
||||
MessageRepository,
|
||||
MessageService,
|
||||
SubscriberService,
|
||||
UserService,
|
||||
UserRepository,
|
||||
RoleService,
|
||||
RoleRepository,
|
||||
SubscriberRepository,
|
||||
ChannelService,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
MenuService,
|
||||
MenuRepository,
|
||||
{
|
||||
provide: ExtendedI18nService,
|
||||
useValue: {
|
||||
t: jest.fn().mockImplementation((t) => t),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: NlpService,
|
||||
useValue: {
|
||||
getNLP: jest.fn(() => undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingService,
|
||||
useValue: {
|
||||
getConfig: jest.fn(() => ({
|
||||
chatbot: { lang: { default: 'fr' } },
|
||||
})),
|
||||
getSettings: jest.fn(() => ({})),
|
||||
},
|
||||
},
|
||||
EventEmitter2,
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: {
|
||||
del: jest.fn(),
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
},
|
||||
LoggerService,
|
||||
],
|
||||
}).compile();
|
||||
messageService = module.get<MessageService>(MessageService);
|
||||
userService = module.get<UserService>(UserService);
|
||||
subscriberService = module.get<SubscriberService>(SubscriberService);
|
||||
messageController = module.get<MessageController>(MessageController);
|
||||
message = await messageService.findOne({ mid: 'mid-1' });
|
||||
sender = await subscriberService.findOne(message.sender);
|
||||
recipient = await subscriberService.findOne(message.recipient);
|
||||
user = await userService.findOne(message.sentBy);
|
||||
allSubscribers = await subscriberService.findAll();
|
||||
allUsers = await userService.findAll();
|
||||
allMessages = await messageService.findAll();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('count', () => {
|
||||
it('should count messages', async () => {
|
||||
jest.spyOn(messageService, 'count');
|
||||
const result = await messageController.filterCount();
|
||||
|
||||
expect(messageService.count).toHaveBeenCalled();
|
||||
expect(result).toEqual({ count: messageFixtures.length });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find message by id, and populate its corresponding sender and recipient', async () => {
|
||||
jest.spyOn(messageService, 'findOneAndPopulate');
|
||||
const result = await messageController.findOne(message.id, [
|
||||
'sender',
|
||||
'recipient',
|
||||
]);
|
||||
|
||||
expect(messageService.findOneAndPopulate).toHaveBeenCalledWith(
|
||||
message.id,
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
...messageFixtures.find(({ mid }) => mid === message.mid),
|
||||
sender,
|
||||
recipient,
|
||||
sentBy: user.id,
|
||||
});
|
||||
});
|
||||
it('should find message by id', async () => {
|
||||
jest.spyOn(messageService, 'findOne');
|
||||
const result = await messageController.findOne(message.id, []);
|
||||
|
||||
expect(messageService.findOne).toHaveBeenCalledWith(message.id);
|
||||
expect(result).toEqualPayload({
|
||||
...messageFixtures.find(({ mid }) => mid === message.mid),
|
||||
sender: sender.id,
|
||||
recipient: recipient.id,
|
||||
sentBy: user.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPage', () => {
|
||||
const pageQuery = getPageQuery<Message>();
|
||||
it('should find messages', async () => {
|
||||
jest.spyOn(messageService, 'findPage');
|
||||
const result = await messageController.findPage(pageQuery, [], {});
|
||||
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,
|
||||
}));
|
||||
|
||||
expect(messageService.findPage).toHaveBeenCalledWith({}, pageQuery);
|
||||
expect(result).toEqualPayload(messagesWithSenderAndRecipient);
|
||||
});
|
||||
|
||||
it('should find messages, and foreach message populate the corresponding sender and recipient', async () => {
|
||||
jest.spyOn(messageService, 'findPageAndPopulate');
|
||||
const result = await messageController.findPage(
|
||||
pageQuery,
|
||||
['sender', 'recipient'],
|
||||
{},
|
||||
);
|
||||
const messages = 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(messageService.findPageAndPopulate).toHaveBeenCalledWith(
|
||||
{},
|
||||
pageQuery,
|
||||
);
|
||||
expect(result).toEqualPayload(messages);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
api/src/chat/controllers/message.controller.ts
Normal file
169
api/src/chat/controllers/message.controller.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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 {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { Request } from 'express'; // Import the Express request and response types
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { ChannelService } from '@/channel/channel.service';
|
||||
import { GenericEventWrapper } from '@/channel/lib/EventWrapper';
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
|
||||
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
|
||||
import { MessageCreateDto } from '../dto/message.dto';
|
||||
import { Message, MessageStub } from '../schemas/message.schema';
|
||||
import {
|
||||
OutgoingMessage,
|
||||
OutgoingMessageFormat,
|
||||
StdOutgoingEnvelope,
|
||||
StdOutgoingMessage,
|
||||
StdOutgoingTextMessage,
|
||||
} from '../schemas/types/message';
|
||||
import { MessageService } from '../services/message.service';
|
||||
import { SubscriberService } from '../services/subscriber.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('message')
|
||||
export class MessageController extends BaseController<Message, MessageStub> {
|
||||
constructor(
|
||||
private readonly messageService: MessageService,
|
||||
private readonly subscriberService: SubscriberService,
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {
|
||||
super(messageService);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Message>,
|
||||
@Query(PopulatePipe)
|
||||
populate: string[],
|
||||
@Query(
|
||||
new SearchFilterPipe<Message>({ allowedFields: ['recipient', 'sender'] }),
|
||||
)
|
||||
filters: TFilterQuery<Message>,
|
||||
) {
|
||||
return this.canPopulate(populate, ['recipient', 'sender', 'sentBy'])
|
||||
? await this.messageService.findPageAndPopulate(filters, pageQuery)
|
||||
: await this.messageService.findPage(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the filtered number of messages.
|
||||
* @returns A promise that resolves to an object representing the filtered number of messages.
|
||||
*/
|
||||
@Get('count')
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<Message>({
|
||||
allowedFields: ['recipient', 'sender'],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<Message>,
|
||||
) {
|
||||
return await this.count(filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@Query(PopulatePipe)
|
||||
populate: string[],
|
||||
) {
|
||||
const doc = this.canPopulate(populate, ['recipient', 'sender', 'sentBy'])
|
||||
? await this.messageService.findOneAndPopulate(id)
|
||||
: await this.messageService.findOne(id);
|
||||
if (!doc) {
|
||||
this.logger.warn(`Unable to find Message by id ${id}`);
|
||||
throw new NotFoundException(`Message with ID ${id} not found`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Post()
|
||||
async create(@Body() messageDto: MessageCreateDto, @Req() req: Request) {
|
||||
//TODO : Investigate if recipient and inReplyTo should be updated to required in dto
|
||||
if (!messageDto.recipient || !messageDto.inReplyTo) {
|
||||
throw new BadRequestException('MessageController send : invalid params');
|
||||
}
|
||||
|
||||
const subscriber = await this.subscriberService.findOne(
|
||||
messageDto.recipient,
|
||||
);
|
||||
if (!subscriber) {
|
||||
this.logger.warn(
|
||||
`Unable to find subscriber by id ${messageDto.recipient}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
`Subscriber with ID ${messageDto.recipient} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.channelService.findChannel(subscriber?.channel.name)) {
|
||||
throw new BadRequestException(`Subscriber channel not found`);
|
||||
}
|
||||
|
||||
const envelope: StdOutgoingEnvelope = {
|
||||
format: OutgoingMessageFormat.text,
|
||||
message: messageDto.message as StdOutgoingTextMessage,
|
||||
};
|
||||
const channelHandler = this.channelService.getChannelHandler(
|
||||
subscriber.channel.name,
|
||||
);
|
||||
const event = new GenericEventWrapper(channelHandler, {
|
||||
senderId: subscriber.foreign_id,
|
||||
messageId: messageDto.inReplyTo,
|
||||
});
|
||||
|
||||
event.setSender(subscriber);
|
||||
try {
|
||||
const { mid } = await channelHandler.sendMessage(event, envelope, {}, {});
|
||||
// Trigger sent message event
|
||||
const sentMessage: Omit<OutgoingMessage, keyof BaseSchema> = {
|
||||
mid,
|
||||
recipient: subscriber.id,
|
||||
message: messageDto.message as StdOutgoingMessage,
|
||||
sentBy: req.session?.passport?.user.id,
|
||||
read: false,
|
||||
delivery: false,
|
||||
};
|
||||
this.eventEmitter.emit('hook:chatbot:sent', sentMessage);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.debug('MessageController send : Unable to send message', err);
|
||||
throw new BadRequestException(
|
||||
'MessageController send : unable to send message',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
191
api/src/chat/controllers/subscriber.controller.spec.ts
Normal file
191
api/src/chat/controllers/subscriber.controller.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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 { UserModel, User } from '@/user/schemas/user.schema';
|
||||
import { RoleService } from '@/user/services/role.service';
|
||||
import {
|
||||
installSubscriberFixtures,
|
||||
subscriberFixtures,
|
||||
} 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 { SocketEventDispatcherService } from '@/websocket/services/socket-event-dispatcher.service';
|
||||
import { WebsocketGateway } from '@/websocket/websocket.gateway';
|
||||
|
||||
import { UserService } from './../../user/services/user.service';
|
||||
import { LabelService } from './../services/label.service';
|
||||
import { SubscriberController } from './subscriber.controller';
|
||||
import { LabelRepository } from '../repositories/label.repository';
|
||||
import { SubscriberRepository } from '../repositories/subscriber.repository';
|
||||
import { LabelModel, Label } from '../schemas/label.schema';
|
||||
import { SubscriberModel, Subscriber } from '../schemas/subscriber.schema';
|
||||
import { SubscriberService } from '../services/subscriber.service';
|
||||
|
||||
describe('SubscriberController', () => {
|
||||
let subscriberController: SubscriberController;
|
||||
let subscriberService: SubscriberService;
|
||||
let labelService: LabelService;
|
||||
let userService: UserService;
|
||||
let subscriber: Subscriber;
|
||||
let allLabels: Label[];
|
||||
let allSubscribers: Subscriber[];
|
||||
let allUsers: User[];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [SubscriberController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installSubscriberFixtures),
|
||||
MongooseModule.forFeature([
|
||||
SubscriberModel,
|
||||
LabelModel,
|
||||
UserModel,
|
||||
RoleModel,
|
||||
PermissionModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
SubscriberRepository,
|
||||
SubscriberService,
|
||||
LabelService,
|
||||
LabelRepository,
|
||||
UserService,
|
||||
WebsocketGateway,
|
||||
SocketEventDispatcherService,
|
||||
UserRepository,
|
||||
RoleService,
|
||||
RoleRepository,
|
||||
EventEmitter2,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
],
|
||||
}).compile();
|
||||
subscriberService = module.get<SubscriberService>(SubscriberService);
|
||||
labelService = module.get<LabelService>(LabelService);
|
||||
userService = module.get<UserService>(UserService);
|
||||
|
||||
subscriberController =
|
||||
module.get<SubscriberController>(SubscriberController);
|
||||
subscriber = await subscriberService.findOne({
|
||||
first_name: 'Jhon',
|
||||
});
|
||||
allLabels = await labelService.findAll();
|
||||
allSubscribers = await subscriberService.findAll();
|
||||
allUsers = await userService.findAll();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('count', () => {
|
||||
it('should count subscribers', async () => {
|
||||
jest.spyOn(subscriberService, 'count');
|
||||
const result = await subscriberController.filterCount();
|
||||
|
||||
expect(subscriberService.count).toHaveBeenCalled();
|
||||
expect(result).toEqual({ count: subscriberFixtures.length });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find one subscriber by id', async () => {
|
||||
jest.spyOn(subscriberService, 'findOne');
|
||||
const result = await subscriberService.findOne(subscriber.id);
|
||||
const labelIDs = allLabels
|
||||
.filter((label) => subscriber.labels.includes(label.id))
|
||||
.map(({ id }) => id);
|
||||
|
||||
expect(subscriberService.findOne).toHaveBeenCalledWith(subscriber.id);
|
||||
expect(result).toEqualPayload({
|
||||
...subscriberFixtures.find(
|
||||
({ first_name }) => first_name === subscriber.first_name,
|
||||
),
|
||||
labels: labelIDs,
|
||||
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id).id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should find one subscriber by id, and populate its corresponding labels', async () => {
|
||||
jest.spyOn(subscriberService, 'findOneAndPopulate');
|
||||
const result = await subscriberController.findOne(subscriber.id, [
|
||||
'labels',
|
||||
]);
|
||||
|
||||
expect(subscriberService.findOneAndPopulate).toHaveBeenCalledWith(
|
||||
subscriber.id,
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
...subscriberFixtures.find(
|
||||
({ first_name }) => first_name === subscriber.first_name,
|
||||
),
|
||||
labels: allLabels.filter((label) =>
|
||||
subscriber.labels.includes(label.id),
|
||||
),
|
||||
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPage', () => {
|
||||
const pageQuery = getPageQuery<Subscriber>();
|
||||
it('should find subscribers', async () => {
|
||||
jest.spyOn(subscriberService, 'findPage');
|
||||
const result = await subscriberController.findPage(pageQuery, [], {});
|
||||
const subscribersWithIds = allSubscribers.map(({ labels, ...rest }) => ({
|
||||
...rest,
|
||||
labels: allLabels
|
||||
.filter((label) => labels.includes(label.id))
|
||||
.map(({ id }) => id),
|
||||
}));
|
||||
|
||||
expect(subscriberService.findPage).toHaveBeenCalledWith({}, pageQuery);
|
||||
expect(result).toEqualPayload(subscribersWithIds.sort(sortRowsBy));
|
||||
});
|
||||
|
||||
it('should find subscribers, and foreach subscriber populate the corresponding labels', async () => {
|
||||
jest.spyOn(subscriberService, 'findPageAndPopulate');
|
||||
const result = await subscriberController.findPage(
|
||||
pageQuery,
|
||||
['labels'],
|
||||
{},
|
||||
);
|
||||
const subscribersWithLabels = allSubscribers.map(
|
||||
({ labels, ...rest }) => ({
|
||||
...rest,
|
||||
labels: allLabels.filter((label) => labels.includes(label.id)),
|
||||
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(subscriberService.findPageAndPopulate).toHaveBeenCalledWith(
|
||||
{},
|
||||
pageQuery,
|
||||
);
|
||||
expect(result).toEqualPayload(subscribersWithLabels.sort(sortRowsBy));
|
||||
});
|
||||
});
|
||||
});
|
||||
146
api/src/chat/controllers/subscriber.controller.ts
Normal file
146
api/src/chat/controllers/subscriber.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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 {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { Roles } from '@/utils/decorators/roles.decorator';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
import { generateInitialsAvatar } from '@/utils/helpers/avatar';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
|
||||
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
|
||||
import { SubscriberUpdateDto } from '../dto/subscriber.dto';
|
||||
import { Subscriber, SubscriberStub } from '../schemas/subscriber.schema';
|
||||
import { SubscriberService } from '../services/subscriber.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('subscriber')
|
||||
export class SubscriberController extends BaseController<
|
||||
Subscriber,
|
||||
SubscriberStub
|
||||
> {
|
||||
constructor(
|
||||
private readonly subscriberService: SubscriberService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(subscriberService);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Subscriber>,
|
||||
@Query(PopulatePipe)
|
||||
populate: string[],
|
||||
@Query(
|
||||
new SearchFilterPipe<Subscriber>({
|
||||
// TODO : Check if the field email should be added to Subscriber schema
|
||||
allowedFields: [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'assignedTo',
|
||||
'labels',
|
||||
'channel.name',
|
||||
],
|
||||
}),
|
||||
)
|
||||
filters: TFilterQuery<Subscriber>,
|
||||
) {
|
||||
return this.canPopulate(populate, ['labels', 'assignedTo', 'avatar'])
|
||||
? await this.subscriberService.findPageAndPopulate(filters, pageQuery)
|
||||
: await this.subscriberService.findPage(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the filtered number of subscribers.
|
||||
* @returns A promise that resolves to an object representing the filtered number of subscribers.
|
||||
*/
|
||||
@Get('count')
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<Subscriber>({
|
||||
allowedFields: [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'assignedTo',
|
||||
'labels',
|
||||
'channel.name',
|
||||
],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<Subscriber>,
|
||||
) {
|
||||
return await this.count(filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@Query(PopulatePipe)
|
||||
populate: string[],
|
||||
) {
|
||||
const doc = this.canPopulate(populate, ['labels', 'assignedTo', 'avatar'])
|
||||
? await this.subscriberService.findOneAndPopulate(id)
|
||||
: await this.subscriberService.findOne(id);
|
||||
if (!doc) {
|
||||
this.logger.warn(`Unable to find Subscriber by id ${id}`);
|
||||
throw new NotFoundException(`Subscriber with ID ${id} not found`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Roles('public')
|
||||
@Get(':foreign_id/profile_pic')
|
||||
async findProfilePic(
|
||||
@Param('foreign_id') foreign_id: string,
|
||||
): Promise<StreamableFile> {
|
||||
try {
|
||||
const pic = await this.subscriberService.findProfilePic(foreign_id);
|
||||
return pic;
|
||||
} catch (e) {
|
||||
const [subscriber] = await this.subscriberService.find({ foreign_id });
|
||||
if (subscriber) {
|
||||
return generateInitialsAvatar(subscriber);
|
||||
} else {
|
||||
throw new NotFoundException(
|
||||
`Subscriber with ID ${foreign_id} not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Patch(':id')
|
||||
async updateOne(
|
||||
@Param('id') id: string,
|
||||
@Body() subscriberUpdate: SubscriberUpdateDto,
|
||||
) {
|
||||
const result = await this.subscriberService.updateOne(id, subscriberUpdate);
|
||||
if (!result) {
|
||||
this.logger.warn(`Unable to update Subscriber by id ${id}`);
|
||||
throw new NotFoundException(`Subscriber with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
205
api/src/chat/controllers/translation.controller.spec.ts
Normal file
205
api/src/chat/controllers/translation.controller.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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 { NotFoundException } from '@nestjs/common';
|
||||
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 { ContentRepository } from '@/cms/repositories/content.repository';
|
||||
import { MenuRepository } from '@/cms/repositories/menu.repository';
|
||||
import { ContentModel } from '@/cms/schemas/content.schema';
|
||||
import { MenuModel } from '@/cms/schemas/menu.schema';
|
||||
import { ContentService } from '@/cms/services/content.service';
|
||||
import { MenuService } from '@/cms/services/menu.service';
|
||||
import { ExtendedI18nService } from '@/extended-i18n.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { NlpService } from '@/nlp/services/nlp.service';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { NOT_FOUND_ID } from '@/utils/constants/mock';
|
||||
import {
|
||||
installTranslationFixtures,
|
||||
translationFixtures,
|
||||
} from '@/utils/test/fixtures/translation';
|
||||
import { getPageQuery } from '@/utils/test/pagination';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { MessageController } from './message.controller';
|
||||
import { TranslationController } from './translation.controller';
|
||||
import { TranslationUpdateDto } from '../dto/translation.dto';
|
||||
import { BlockRepository } from '../repositories/block.repository';
|
||||
import { MessageRepository } from '../repositories/message.repository';
|
||||
import { SubscriberRepository } from '../repositories/subscriber.repository';
|
||||
import { TranslationRepository } from '../repositories/translation.repository';
|
||||
import { BlockModel } from '../schemas/block.schema';
|
||||
import { MessageModel } from '../schemas/message.schema';
|
||||
import { SubscriberModel } from '../schemas/subscriber.schema';
|
||||
import { Translation, TranslationModel } from '../schemas/translation.schema';
|
||||
import { BlockService } from '../services/block.service';
|
||||
import { MessageService } from '../services/message.service';
|
||||
import { SubscriberService } from '../services/subscriber.service';
|
||||
import { TranslationService } from '../services/translation.service';
|
||||
|
||||
describe('TranslationController', () => {
|
||||
let translationController: TranslationController;
|
||||
let translationService: TranslationService;
|
||||
let translation: Translation;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [MessageController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installTranslationFixtures),
|
||||
MongooseModule.forFeature([
|
||||
SubscriberModel,
|
||||
TranslationModel,
|
||||
MessageModel,
|
||||
AttachmentModel,
|
||||
MenuModel,
|
||||
BlockModel,
|
||||
ContentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
TranslationController,
|
||||
TranslationService,
|
||||
TranslationRepository,
|
||||
MessageService,
|
||||
MessageRepository,
|
||||
SubscriberService,
|
||||
SubscriberRepository,
|
||||
ChannelService,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
MenuService,
|
||||
MenuRepository,
|
||||
{
|
||||
provide: NlpService,
|
||||
useValue: {
|
||||
getNLP: jest.fn(() => undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingService,
|
||||
useValue: {
|
||||
getConfig: jest.fn(() => ({
|
||||
chatbot: { lang: { default: 'fr' } },
|
||||
})),
|
||||
getSettings: jest.fn(() => ({})),
|
||||
},
|
||||
},
|
||||
BlockService,
|
||||
BlockRepository,
|
||||
ContentService,
|
||||
ContentRepository,
|
||||
{
|
||||
provide: PluginService,
|
||||
useValue: {},
|
||||
},
|
||||
EventEmitter2,
|
||||
LoggerService,
|
||||
{
|
||||
provide: ExtendedI18nService,
|
||||
useValue: {
|
||||
t: jest.fn().mockImplementation((t) => t),
|
||||
initDynamicTranslations: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: {
|
||||
del: jest.fn(),
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
},
|
||||
LoggerService,
|
||||
],
|
||||
}).compile();
|
||||
translationService = module.get<TranslationService>(TranslationService);
|
||||
translationController = module.get<TranslationController>(
|
||||
TranslationController,
|
||||
);
|
||||
translation = await translationService.findOne({ str: 'Welcome' });
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('count', () => {
|
||||
it('should count translations', async () => {
|
||||
jest.spyOn(translationService, 'count');
|
||||
const result = await translationController.filterCount();
|
||||
|
||||
expect(translationService.count).toHaveBeenCalled();
|
||||
expect(result).toEqual({ count: translationFixtures.length });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find one translation by id', async () => {
|
||||
jest.spyOn(translationService, 'findOne');
|
||||
const result = await translationController.findOne(translation.id);
|
||||
|
||||
expect(translationService.findOne).toHaveBeenCalledWith(translation.id);
|
||||
expect(result).toEqualPayload(
|
||||
translationFixtures.find(({ str }) => str === translation.str),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPage', () => {
|
||||
const pageQuery = getPageQuery<Translation>();
|
||||
it('should find translations', async () => {
|
||||
jest.spyOn(translationService, 'findPage');
|
||||
const result = await translationController.findPage(pageQuery, {});
|
||||
|
||||
expect(translationService.findPage).toHaveBeenCalledWith({}, pageQuery);
|
||||
expect(result).toEqualPayload(translationFixtures);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOne', () => {
|
||||
const translationUpdateDto: TranslationUpdateDto = {
|
||||
str: 'Welcome !',
|
||||
};
|
||||
it('should update one translation by id', async () => {
|
||||
jest.spyOn(translationService, 'updateOne');
|
||||
const result = await translationController.updateOne(
|
||||
translation.id,
|
||||
translationUpdateDto,
|
||||
);
|
||||
|
||||
expect(translationService.updateOne).toHaveBeenCalledWith(
|
||||
translation.id,
|
||||
translationUpdateDto,
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
...translationFixtures.find(({ str }) => str === translation.str),
|
||||
...translationUpdateDto,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a NotFoundException when attempting to update a translation by id', async () => {
|
||||
jest.spyOn(translationService, 'updateOne');
|
||||
await expect(
|
||||
translationController.updateOne(NOT_FOUND_ID, translationUpdateDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
api/src/chat/controllers/translation.controller.ts
Normal file
142
api/src/chat/controllers/translation.controller.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
|
||||
import { TranslationUpdateDto } from '../dto/translation.dto';
|
||||
import { Translation } from '../schemas/translation.schema';
|
||||
import { TranslationService } from '../services/translation.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('translation')
|
||||
export class TranslationController extends BaseController<Translation> {
|
||||
constructor(
|
||||
private readonly translationService: TranslationService,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(translationService);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Translation>,
|
||||
@Query(new SearchFilterPipe<Translation>({ allowedFields: ['str'] }))
|
||||
filters: TFilterQuery<Translation>,
|
||||
) {
|
||||
return await this.translationService.findPage(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the filtered number of translations.
|
||||
* @returns A promise that resolves to an object representing the filtered number of translations.
|
||||
*/
|
||||
@Get('count')
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<Translation>({
|
||||
allowedFields: ['str'],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<Translation>,
|
||||
) {
|
||||
return await this.count(filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const doc = await this.translationService.findOne(id);
|
||||
if (!doc) {
|
||||
this.logger.warn(`Unable to find Translation by id ${id}`);
|
||||
throw new NotFoundException(`Translation with ID ${id} not found`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@CsrfCheck(true)
|
||||
@Patch(':id')
|
||||
async updateOne(
|
||||
@Param('id') id: string,
|
||||
@Body() translationUpdate: TranslationUpdateDto,
|
||||
) {
|
||||
const result = await this.translationService.updateOne(
|
||||
id,
|
||||
translationUpdate,
|
||||
);
|
||||
if (!result) {
|
||||
this.logger.warn(`Unable to update Translation by id ${id}`);
|
||||
throw new NotFoundException(`Translation with ID ${id} not found`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh translations : Add new strings and remove old ones
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Post('refresh')
|
||||
async refresh(): Promise<any> {
|
||||
const settings = await this.settingService.getSettings();
|
||||
const languages = settings.nlp_settings.languages;
|
||||
const defaultTrans: Translation['translations'] = languages.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr] = '';
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: string },
|
||||
);
|
||||
// Scan Blocks
|
||||
return this.translationService
|
||||
.getAllBlockStrings()
|
||||
.then(async (strings: string[]) => {
|
||||
const settingStrings =
|
||||
await this.translationService.getSettingStrings();
|
||||
// Scan global settings
|
||||
strings = strings.concat(settingStrings);
|
||||
// Filter unique and not empty messages
|
||||
strings = strings.filter((str, pos) => {
|
||||
return str && strings.indexOf(str) == pos;
|
||||
});
|
||||
// Perform refresh
|
||||
const queue = strings.map((str) =>
|
||||
this.translationService.findOneOrCreate(
|
||||
{ str },
|
||||
{ str, translations: defaultTrans as any, translated: 100 },
|
||||
),
|
||||
);
|
||||
return Promise.all(queue).then(() => {
|
||||
// Purge non existing translations
|
||||
return this.translationService.deleteMany({
|
||||
str: { $nin: strings },
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
149
api/src/chat/dto/block.dto.ts
Normal file
149
api/src/chat/dto/block.dto.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
OmitType,
|
||||
PartialType,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
|
||||
|
||||
import { CaptureVar } from '../schemas/types/capture-var';
|
||||
import { BlockMessage } from '../schemas/types/message';
|
||||
import { BlockOptions } from '../schemas/types/options';
|
||||
import { Pattern } from '../schemas/types/pattern';
|
||||
import { Position } from '../schemas/types/position';
|
||||
import { IsMessage } from '../validation-rules/is-message';
|
||||
import { IsPatternList } from '../validation-rules/is-pattern-list';
|
||||
import { IsPosition } from '../validation-rules/is-position';
|
||||
import { IsVarCapture } from '../validation-rules/is-valid-capture';
|
||||
|
||||
export class BlockCreateDto {
|
||||
@ApiProperty({ description: 'Block name', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Block patterns', type: Array })
|
||||
@IsOptional()
|
||||
@IsPatternList({ message: 'Patterns list is invalid' })
|
||||
patterns?: Pattern[] = [];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsObjectId({ each: true, message: 'Trigger label must be a valid objectId' })
|
||||
trigger_labels?: string[] = [];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Block assign labels', type: Array })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsObjectId({ each: true, message: 'Assign label must be a valid objectId' })
|
||||
assign_labels?: string[] = [];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Block trigger channels', type: Array })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
trigger_channels?: string[] = [];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Block options', type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
options?: BlockOptions;
|
||||
|
||||
@ApiProperty({ description: 'Block message', type: Object })
|
||||
@IsNotEmpty()
|
||||
@IsMessage({ message: 'Message is invalid' })
|
||||
message: BlockMessage;
|
||||
|
||||
@ApiPropertyOptional({ description: 'next blocks', type: Array })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsObjectId({ each: true, message: 'Next block must be a valid objectId' })
|
||||
nextBlocks?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'attached blocks', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsObjectId({
|
||||
message: 'Attached block must be a valid objectId',
|
||||
})
|
||||
attachedBlock?: string;
|
||||
|
||||
@ApiProperty({ description: 'Block category', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsObjectId({ message: 'Category must be a valid objectId' })
|
||||
category: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Block has started conversation',
|
||||
type: Boolean,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
starts_conversation?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Block capture vars',
|
||||
type: Array,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsVarCapture({ message: 'Capture vars are invalid' })
|
||||
capture_vars?: CaptureVar[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Block position',
|
||||
type: Object,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsPosition({ message: 'Position is invalid' })
|
||||
position: Position;
|
||||
}
|
||||
|
||||
export class BlockUpdateDto extends PartialType(
|
||||
OmitType(BlockCreateDto, [
|
||||
'patterns',
|
||||
'trigger_labels',
|
||||
'assign_labels',
|
||||
'trigger_channels',
|
||||
]),
|
||||
) {
|
||||
@ApiPropertyOptional({ description: 'Block patterns', type: Array })
|
||||
@IsOptional()
|
||||
@IsPatternList({ message: 'Patterns list is invalid' })
|
||||
patterns?: Pattern[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsObjectId({ each: true, message: 'Trigger label must be a valid objectId' })
|
||||
trigger_labels?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Block assign labels', type: Array })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsObjectId({ each: true, message: 'Assign label must be a valid objectId' })
|
||||
assign_labels?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Block trigger channels', type: Array })
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
trigger_channels?: string[];
|
||||
}
|
||||
42
api/src/chat/dto/category.dto.ts
Normal file
42
api/src/chat/dto/category.dto.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CategoryCreateDto {
|
||||
@ApiProperty({ description: 'Category label', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
label: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Category is builtin', type: Boolean })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
builtin?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Zoom', type: Number })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
zoom?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Offset', type: Array })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
offset?: [number, number];
|
||||
}
|
||||
|
||||
export class CategoryUpdateDto extends PartialType(CategoryCreateDto) {}
|
||||
25
api/src/chat/dto/context-var.dto.ts
Normal file
25
api/src/chat/dto/context-var.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 { ApiProperty, PartialType } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ContextVarCreateDto {
|
||||
@ApiProperty({ description: 'Context var label', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
label: string;
|
||||
|
||||
@ApiProperty({ description: 'Context var name', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class ContextVarUpdateDto extends PartialType(ContextVarCreateDto) {}
|
||||
59
api/src/chat/dto/conversation.dto.ts
Normal file
59
api/src/chat/dto/conversation.dto.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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
|
||||
|
||||
import { Context } from './../schemas/types/context';
|
||||
|
||||
export class ConversationCreateDto {
|
||||
@ApiProperty({ description: 'Conversation sender', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsObjectId({
|
||||
message: 'Sender must be a valid objectId',
|
||||
})
|
||||
sender: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Conversation is active', type: Boolean })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
active?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Conversation context', type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
context?: Context;
|
||||
|
||||
@ApiProperty({ description: 'Current conversation', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsObjectId({
|
||||
message: 'Current must be a valid objectId',
|
||||
})
|
||||
current: string;
|
||||
|
||||
@ApiProperty({ description: 'next conversation', type: Array })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsObjectId({
|
||||
each: true,
|
||||
message: 'next must be a valid objectId',
|
||||
})
|
||||
next: string[];
|
||||
}
|
||||
42
api/src/chat/dto/label.dto.ts
Normal file
42
api/src/chat/dto/label.dto.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
IsObject,
|
||||
} from 'class-validator';
|
||||
|
||||
export class LabelCreateDto {
|
||||
@ApiProperty({ description: 'Label title', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: 'Label name', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@Matches(/^[A-Z_0-9]+$/)
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Label description', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Label id', type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
label_id?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class LabelUpdateDto extends PartialType(LabelCreateDto) {}
|
||||
80
api/src/chat/dto/message.dto.ts
Normal file
80
api/src/chat/dto/message.dto.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsString,
|
||||
IsOptional,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
|
||||
|
||||
import {
|
||||
StdIncomingMessage,
|
||||
StdOutgoingMessage,
|
||||
} from '../schemas/types/message';
|
||||
import { IsValidMessageText } from '../validation-rules/is-valid-message-text';
|
||||
|
||||
export class MessageCreateDto {
|
||||
@ApiProperty({ description: 'Message id', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mid?: string;
|
||||
|
||||
@ApiProperty({ description: 'Reply to Message id', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inReplyTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Message sender', type: String })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsObjectId({ message: 'Sender must be a valid ObjectId' })
|
||||
sender?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Message recipient', type: String })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsObjectId({ message: 'Recipient must be a valid ObjectId' })
|
||||
recipient?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Message sent by', type: String })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsObjectId({ message: 'SentBy must be a valid ObjectId' })
|
||||
sentBy?: string;
|
||||
|
||||
@ApiProperty({ description: 'Message', type: Object })
|
||||
@IsObject()
|
||||
@IsNotEmpty()
|
||||
@IsValidMessageText({ message: 'Message should have text property' })
|
||||
message: StdOutgoingMessage | StdIncomingMessage;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Message is read', type: Boolean })
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
read?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Message is delivered', type: Boolean })
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
delivery?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Message is handed over', type: Boolean })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
handover?: boolean;
|
||||
}
|
||||
|
||||
export class MessageUpdateDto extends PartialType(MessageCreateDto) {}
|
||||
116
api/src/chat/dto/subscriber.dto.ts
Normal file
116
api/src/chat/dto/subscriber.dto.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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 { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsDate,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
|
||||
|
||||
import { ChannelData } from '../schemas/types/channel';
|
||||
import { IsChannelData } from '../validation-rules/is-channel-data';
|
||||
|
||||
export class SubscriberCreateDto {
|
||||
@ApiProperty({ description: 'Subscriber first name', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
first_name: string;
|
||||
|
||||
@ApiProperty({ description: 'Subscriber last name', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
last_name: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Subscriber locale', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Subscriber timezone', type: Number })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
timezone?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Subscriber language', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
language: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Subscriber gender', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gender: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Subscriber country', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
country: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Subscriber foreign id', type: String })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
foreign_id: string;
|
||||
|
||||
@ApiProperty({ description: 'Subscriber labels', type: Array })
|
||||
@IsNotEmpty()
|
||||
@IsArray()
|
||||
@IsObjectId({ each: true, message: 'Label must be a valid ObjectId' })
|
||||
labels: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Subscriber assigned to',
|
||||
type: String,
|
||||
default: null,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsObjectId({ message: 'AssignedTo must be a valid ObjectId' })
|
||||
assignedTo?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Subscriber assigned at',
|
||||
type: Date,
|
||||
default: null,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
assignedAt: Date | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Subscriber last visit',
|
||||
type: Date,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
lastvisit: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Subscriber retained from',
|
||||
type: Date,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
retainedFrom: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Subscriber channel',
|
||||
type: Object,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsChannelData()
|
||||
channel: ChannelData;
|
||||
}
|
||||
|
||||
export class SubscriberUpdateDto extends PartialType(SubscriberCreateDto) {}
|
||||
48
api/src/chat/dto/translation.dto.ts
Normal file
48
api/src/chat/dto/translation.dto.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TranslationCreateDto {
|
||||
@ApiProperty({ description: 'Translation str', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
str: string;
|
||||
|
||||
@ApiProperty({ description: 'Translations', type: Object })
|
||||
@IsNotEmpty()
|
||||
@IsObject()
|
||||
translations: Record<string, string>;
|
||||
|
||||
@ApiProperty({ description: 'Translated', type: Number })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
translated: number;
|
||||
}
|
||||
|
||||
export class TranslationUpdateDto {
|
||||
@ApiPropertyOptional({ description: 'Translation str', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
str?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Translations', type: Object })
|
||||
@IsNotEmpty()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
translations?: Record<string, string>;
|
||||
}
|
||||
13
api/src/chat/helpers/constants.ts
Normal file
13
api/src/chat/helpers/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* VIEW_MORE_PAYLOAD is declared, but never used.
|
||||
*/
|
||||
export const VIEW_MORE_PAYLOAD = 'VIEW_MORE';
|
||||
109
api/src/chat/repositories/block.repository.spec.ts
Normal file
109
api/src/chat/repositories/block.repository.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 { MongooseModule, getModelToken } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Model } from 'mongoose';
|
||||
|
||||
import {
|
||||
blockFixtures,
|
||||
installBlockFixtures,
|
||||
} from '@/utils/test/fixtures/block';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { BlockRepository } from './block.repository';
|
||||
import { CategoryRepository } from './category.repository';
|
||||
import { BlockModel, Block } from '../schemas/block.schema';
|
||||
import { CategoryModel, Category } from '../schemas/category.schema';
|
||||
import { LabelModel } from '../schemas/label.schema';
|
||||
|
||||
describe('BlockRepository', () => {
|
||||
let blockRepository: BlockRepository;
|
||||
let categoryRepository: CategoryRepository;
|
||||
let blockModel: Model<Block>;
|
||||
let category: Category;
|
||||
let hasPreviousBlocks: Block;
|
||||
let hasNextBlocks: Block;
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installBlockFixtures),
|
||||
MongooseModule.forFeature([BlockModel, CategoryModel, LabelModel]),
|
||||
],
|
||||
providers: [BlockRepository, CategoryRepository],
|
||||
}).compile();
|
||||
blockRepository = module.get<BlockRepository>(BlockRepository);
|
||||
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
|
||||
blockModel = module.get<Model<Block>>(getModelToken('Block'));
|
||||
category = await categoryRepository.findOne({ label: 'default' });
|
||||
hasPreviousBlocks = await blockRepository.findOne({
|
||||
name: 'hasPreviousBlocks',
|
||||
});
|
||||
hasNextBlocks = await blockRepository.findOne({
|
||||
name: 'hasNextBlocks',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findOneAndPopulate', () => {
|
||||
it('should find one block by id, and populate its trigger_labels, assign_labels, nextBlocks, attachedBlock, category,previousBlocks', async () => {
|
||||
jest.spyOn(blockModel, 'findById');
|
||||
|
||||
const result = await blockRepository.findOneAndPopulate(hasNextBlocks.id);
|
||||
expect(blockModel.findById).toHaveBeenCalledWith(hasNextBlocks.id);
|
||||
expect(result).toEqualPayload({
|
||||
...blockFixtures.find(({ name }) => name === hasNextBlocks.name),
|
||||
category,
|
||||
nextBlocks: [hasPreviousBlocks],
|
||||
previousBlocks: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAndPopulate', () => {
|
||||
it('should find blocks, and foreach block populate its trigger_labels, assign_labels, attachedBlock, category, previousBlocks', async () => {
|
||||
jest.spyOn(blockModel, 'find');
|
||||
const category = await categoryRepository.findOne({ label: 'default' });
|
||||
const result = await blockRepository.findAndPopulate({});
|
||||
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
|
||||
...blockFixture,
|
||||
category,
|
||||
previousBlocks:
|
||||
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
|
||||
nextBlocks:
|
||||
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
|
||||
}));
|
||||
|
||||
expect(blockModel.find).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(blocksWithCategory);
|
||||
});
|
||||
|
||||
it('should find blocks, and foreach block populate its trigger_labels, assign_labels, nextBlocks, attachedBlock, category', async () => {
|
||||
jest.spyOn(blockModel, 'find');
|
||||
const category = await categoryRepository.findOne({ label: 'default' });
|
||||
const result = await blockRepository.findAndPopulate({});
|
||||
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
|
||||
...blockFixture,
|
||||
category,
|
||||
previousBlocks:
|
||||
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
|
||||
nextBlocks:
|
||||
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
|
||||
}));
|
||||
|
||||
expect(blockModel.find).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(blocksWithCategory);
|
||||
});
|
||||
});
|
||||
});
|
||||
203
api/src/chat/repositories/block.repository.ts
Normal file
203
api/src/chat/repositories/block.repository.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* 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, Optional } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
TFilterQuery,
|
||||
Model,
|
||||
Document,
|
||||
Types,
|
||||
Query,
|
||||
UpdateQuery,
|
||||
UpdateWithAggregationPipeline,
|
||||
} from 'mongoose';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
|
||||
|
||||
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
|
||||
import { Block, BlockFull } from '../schemas/block.schema';
|
||||
|
||||
@Injectable()
|
||||
export class BlockRepository extends BaseRepository<
|
||||
Block,
|
||||
| 'trigger_labels'
|
||||
| 'assign_labels'
|
||||
| 'nextBlocks'
|
||||
| 'attachedBlock'
|
||||
| 'category'
|
||||
| 'previousBlocks'
|
||||
| 'attachedToBlock'
|
||||
> {
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(
|
||||
@InjectModel(Block.name) readonly model: Model<Block>,
|
||||
@Optional() logger?: LoggerService,
|
||||
) {
|
||||
super(model, Block);
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the `url` field in the attachment payload is deprecated, and logs an error if found.
|
||||
*
|
||||
* @param block - The block DTO (create or update) to check.
|
||||
*/
|
||||
checkDeprecatedAttachmentUrl(block: BlockCreateDto | BlockUpdateDto) {
|
||||
if (
|
||||
block.message &&
|
||||
'attachment' in block.message &&
|
||||
'url' in block.message.attachment.payload
|
||||
) {
|
||||
this.logger.error(
|
||||
'NOTE: `url` payload has been deprecated in favor of `attachment_id`',
|
||||
block.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-processing logic for creating a new block.
|
||||
*
|
||||
* @param doc - The document that is being created.
|
||||
*/
|
||||
async preCreate(
|
||||
_doc: Document<unknown, object, Block> & Block & { _id: Types.ObjectId },
|
||||
): Promise<void> {
|
||||
if (_doc) this.checkDeprecatedAttachmentUrl(_doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-processing logic for updating a block.
|
||||
*
|
||||
* @param query - The query to update a block.
|
||||
* @param criteria - The filter criteria for the update query.
|
||||
* @param updates - The update data.
|
||||
*/
|
||||
async preUpdate(
|
||||
_query: Query<
|
||||
Document<Block, any, any>,
|
||||
Document<Block, any, any>,
|
||||
unknown,
|
||||
Block,
|
||||
'findOneAndUpdate'
|
||||
>,
|
||||
_criteria: TFilterQuery<Block>,
|
||||
_updates:
|
||||
| UpdateWithAggregationPipeline
|
||||
| UpdateQuery<Document<Block, any, any>>,
|
||||
): Promise<void> {
|
||||
const updates: BlockUpdateDto = _updates?.['$set'];
|
||||
|
||||
this.checkDeprecatedAttachmentUrl(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processing logic after deleting a block.
|
||||
*
|
||||
* @param query - The delete query.
|
||||
* @param result - The result of the delete operation.
|
||||
*/
|
||||
async postDelete(
|
||||
_query: Query<
|
||||
DeleteResult,
|
||||
Document<Block, any, any>,
|
||||
unknown,
|
||||
Block,
|
||||
'deleteOne' | 'deleteMany'
|
||||
>,
|
||||
result: DeleteResult,
|
||||
) {
|
||||
if (result.deletedCount > 0) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-processing logic before deleting a block.
|
||||
* It handles removing references to the block from other related blocks.
|
||||
*
|
||||
* @param query - The delete query.
|
||||
* @param criteria - The filter criteria for finding blocks to delete.
|
||||
*/
|
||||
async preDelete(
|
||||
_query: Query<
|
||||
DeleteResult,
|
||||
Document<Block, any, any>,
|
||||
unknown,
|
||||
Block,
|
||||
'deleteOne' | 'deleteMany'
|
||||
>,
|
||||
criteria: TFilterQuery<Block>,
|
||||
) {
|
||||
const docsToDelete = await this.model.find(criteria);
|
||||
const idsToDelete = docsToDelete.map(({ id }) => id);
|
||||
if (idsToDelete.length > 0) {
|
||||
// Remove from all other blocks
|
||||
await this.model.updateMany(
|
||||
{ attachedBlock: { $in: idsToDelete } },
|
||||
{
|
||||
$set: {
|
||||
attachedBlock: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Remove all other previous blocks
|
||||
await this.model.updateMany(
|
||||
{ nextBlocks: { $in: idsToDelete } },
|
||||
{
|
||||
$pull: {
|
||||
nextBlocks: { $in: idsToDelete },
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds blocks and populates related fields (e.g., labels, attached blocks).
|
||||
*
|
||||
* @param filters - The filter criteria for finding blocks.
|
||||
*
|
||||
* @returns The populated block results.
|
||||
*/
|
||||
async findAndPopulate(filters: TFilterQuery<Block>) {
|
||||
const query = this.findQuery(filters).populate([
|
||||
'trigger_labels',
|
||||
'assign_labels',
|
||||
'nextBlocks',
|
||||
'attachedBlock',
|
||||
'category',
|
||||
'previousBlocks',
|
||||
'attachedToBlock',
|
||||
]);
|
||||
return await this.execute(query, BlockFull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single block by ID and populates related fields (e.g., labels, attached blocks).
|
||||
*
|
||||
* @param id - The ID of the block to find.
|
||||
*
|
||||
* @returns The populated block result or null if not found.
|
||||
*/
|
||||
async findOneAndPopulate(id: string) {
|
||||
const query = this.findOneQuery(id).populate([
|
||||
'trigger_labels',
|
||||
'assign_labels',
|
||||
'nextBlocks',
|
||||
'attachedBlock',
|
||||
'category',
|
||||
'previousBlocks',
|
||||
'attachedToBlock',
|
||||
]);
|
||||
return await this.executeOne(query, BlockFull);
|
||||
}
|
||||
}
|
||||
60
api/src/chat/repositories/category.repository.ts
Normal file
60
api/src/chat/repositories/category.repository.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 { ForbiddenException, Injectable, Optional } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Document, Model, Query, TFilterQuery } from 'mongoose';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
|
||||
|
||||
import { Category } from '../schemas/category.schema';
|
||||
import { BlockService } from '../services/block.service';
|
||||
|
||||
@Injectable()
|
||||
export class CategoryRepository extends BaseRepository<Category> {
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
private readonly blockService: BlockService;
|
||||
|
||||
constructor(
|
||||
@InjectModel(Category.name) readonly model: Model<Category>,
|
||||
@Optional() blockService?: BlockService,
|
||||
@Optional() logger?: LoggerService,
|
||||
) {
|
||||
super(model, Category);
|
||||
this.logger = logger;
|
||||
this.blockService = blockService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-processing logic before deleting a category.
|
||||
* It avoids delete a category that contains blocks.
|
||||
*
|
||||
* @param query - The delete query.
|
||||
* @param criteria - The filter criteria for finding blocks to delete.
|
||||
*/
|
||||
async preDelete(
|
||||
_query: Query<
|
||||
DeleteResult,
|
||||
Document<Category, any, any>,
|
||||
unknown,
|
||||
Category,
|
||||
'deleteOne' | 'deleteMany'
|
||||
>,
|
||||
criteria: TFilterQuery<Category>,
|
||||
) {
|
||||
const associatedBlocks = await this.blockService.findOne({
|
||||
category: criteria._id,
|
||||
});
|
||||
if (associatedBlocks) {
|
||||
throw new ForbiddenException(`Category have blocks associated to it`);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
api/src/chat/repositories/context-var.repository.ts
Normal file
23
api/src/chat/repositories/context-var.repository.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
|
||||
import { BaseRepository } from '@/utils/generics/base-repository';
|
||||
|
||||
import { ContextVar } from '../schemas/context-var.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ContextVarRepository extends BaseRepository<ContextVar> {
|
||||
constructor(@InjectModel(ContextVar.name) readonly model: Model<ContextVar>) {
|
||||
super(model, ContextVar);
|
||||
}
|
||||
}
|
||||
71
api/src/chat/repositories/conversation.repository.ts
Normal file
71
api/src/chat/repositories/conversation.repository.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model } from 'mongoose';
|
||||
|
||||
import { BaseRepository } from '@/utils/generics/base-repository';
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationDocument,
|
||||
ConversationFull,
|
||||
} from '../schemas/conversation.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ConversationRepository extends BaseRepository<
|
||||
Conversation,
|
||||
'sender' | 'current' | 'next'
|
||||
> {
|
||||
constructor(
|
||||
@InjectModel(Conversation.name) readonly model: Model<Conversation>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {
|
||||
super(model, Conversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a new conversation is created. This method emits the event
|
||||
* with the newly created conversation document.
|
||||
*
|
||||
* @param created - The newly created conversation document.
|
||||
*/
|
||||
async postCreate(created: ConversationDocument): Promise<void> {
|
||||
this.eventEmitter.emit('hook:chatbot:conversation:start', created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a conversation as ended by setting its `active` status to `false`.
|
||||
*
|
||||
* @param convo The conversation or full conversation object to be ended.
|
||||
*
|
||||
* @returns A promise resolving to the result of the update operation.
|
||||
*/
|
||||
async end(convo: Conversation | ConversationFull) {
|
||||
return await this.updateOne(convo.id, { active: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single conversation by a given criteria and populates the related fields: `sender`, `current`, and `next`.
|
||||
*
|
||||
* @param criteria The search criteria, either a string or a filter query.
|
||||
*
|
||||
* @returns A promise resolving to the populated conversation full object.
|
||||
*/
|
||||
async findOneAndPopulate(criteria: string | FilterQuery<Conversation>) {
|
||||
const query = this.findOneQuery(criteria).populate([
|
||||
'sender',
|
||||
'current',
|
||||
'next',
|
||||
]);
|
||||
return await this.executeOne(query, ConversationFull);
|
||||
}
|
||||
}
|
||||
101
api/src/chat/repositories/label.repository.spec.ts
Normal file
101
api/src/chat/repositories/label.repository.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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, getModelToken } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Model } from 'mongoose';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { labelFixtures } from '@/utils/test/fixtures/label';
|
||||
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 { LabelRepository } from './label.repository';
|
||||
import { SubscriberRepository } from './subscriber.repository';
|
||||
import { LabelModel, Label } from '../schemas/label.schema';
|
||||
import { SubscriberModel, Subscriber } from '../schemas/subscriber.schema';
|
||||
|
||||
describe('LabelRepository', () => {
|
||||
let labelRepository: LabelRepository;
|
||||
let labelModel: Model<Label>;
|
||||
let subscriberRepository: SubscriberRepository;
|
||||
let users: Subscriber[];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installSubscriberFixtures),
|
||||
MongooseModule.forFeature([LabelModel, SubscriberModel]),
|
||||
],
|
||||
providers: [
|
||||
LabelRepository,
|
||||
SubscriberRepository,
|
||||
EventEmitter2,
|
||||
LoggerService,
|
||||
],
|
||||
}).compile();
|
||||
labelRepository = module.get<LabelRepository>(LabelRepository);
|
||||
subscriberRepository =
|
||||
module.get<SubscriberRepository>(SubscriberRepository);
|
||||
labelModel = module.get<Model<Label>>(getModelToken('Label'));
|
||||
users = await subscriberRepository.findAll();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findOneAndPopulate', () => {
|
||||
it('should find one label by id, and populate its users', async () => {
|
||||
jest.spyOn(labelModel, 'findById');
|
||||
const label = await labelRepository.findOne({ name: 'TEST_TITLE_2' });
|
||||
const result = await labelRepository.findOneAndPopulate(label.id);
|
||||
|
||||
expect(labelModel.findById).toHaveBeenCalledWith(label.id);
|
||||
expect(result).toEqualPayload({
|
||||
...labelFixtures.find(({ name }) => name === label.name),
|
||||
users,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllAndPopulate', () => {
|
||||
it('should find all labels, and foreach label populate its corresponding users', async () => {
|
||||
jest.spyOn(labelModel, 'find');
|
||||
const result = await labelRepository.findAllAndPopulate();
|
||||
const labelsWithUsers = labelFixtures.map((label) => ({
|
||||
...label,
|
||||
users,
|
||||
}));
|
||||
|
||||
expect(labelModel.find).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(labelsWithUsers);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageAndPopulate', () => {
|
||||
it('should find labels, and foreach label populate its corresponding users', async () => {
|
||||
const pageQuery = getPageQuery<Label>();
|
||||
jest.spyOn(labelModel, 'find');
|
||||
const result = await labelRepository.findPageAndPopulate({}, pageQuery);
|
||||
const labelsWithUsers = labelFixtures.map((label) => ({
|
||||
...label,
|
||||
users,
|
||||
}));
|
||||
|
||||
expect(labelModel.find).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(labelsWithUsers.sort(sortRowsBy));
|
||||
});
|
||||
});
|
||||
});
|
||||
119
api/src/chat/repositories/label.repository.ts
Normal file
119
api/src/chat/repositories/label.repository.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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 { InjectModel } from '@nestjs/mongoose';
|
||||
import { TFilterQuery, Model, Document, Query } from 'mongoose';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
|
||||
import { Label, LabelDocument, LabelFull } from '../schemas/label.schema';
|
||||
|
||||
@Injectable()
|
||||
export class LabelRepository extends BaseRepository<Label, 'users'> {
|
||||
constructor(
|
||||
@InjectModel(Label.name) readonly model: Model<Label>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(model, Label);
|
||||
}
|
||||
|
||||
/**
|
||||
* After creating a `Label`, this method emits an event and updates the `label_id` field.
|
||||
*
|
||||
* @param created - The created label document instance.
|
||||
*
|
||||
* @returns A promise that resolves when the update operation is complete.
|
||||
*/
|
||||
async postCreate(created: LabelDocument): Promise<void> {
|
||||
this.eventEmitter.emit(
|
||||
'hook:chatbot:label:create',
|
||||
created,
|
||||
async (result: Record<string, any>) => {
|
||||
await this.model.updateOne(
|
||||
{ _id: created._id },
|
||||
{
|
||||
$set: {
|
||||
label_id: {
|
||||
...(created.label_id || {}),
|
||||
...result,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Before deleting a label, this method fetches the label(s) based on the given criteria and emits a delete event.
|
||||
*
|
||||
* @param query - The Mongoose query object used for deletion.
|
||||
* @param criteria - The filter criteria for finding the labels to be deleted.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves once the event is emitted.
|
||||
*/
|
||||
async preDelete(
|
||||
_query: Query<
|
||||
DeleteResult,
|
||||
Document<Label, any, any>,
|
||||
unknown,
|
||||
Label,
|
||||
'deleteOne' | 'deleteMany'
|
||||
>,
|
||||
_criteria: TFilterQuery<Label>,
|
||||
): Promise<void> {
|
||||
const labels = await this.find(
|
||||
typeof _criteria === 'string' ? { _id: _criteria } : _criteria,
|
||||
);
|
||||
this.eventEmitter.emit('hook:chatbot:label:delete', labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all label documents and populates the `users` field which references the subscribers.
|
||||
*
|
||||
* @returns A promise that resolves with an array of fully populated `LabelFull` documents.
|
||||
*/
|
||||
async findAllAndPopulate() {
|
||||
const query = this.findAllQuery().populate(['users']);
|
||||
return await this.execute(query, LabelFull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a paginated list of label documents based on filters and populates the `users` (subscribers) field.
|
||||
*
|
||||
* @param filters - The filter criteria for querying the labels.
|
||||
* @param pageQuery - The pagination query options.
|
||||
*
|
||||
* @returns A promise that resolves with a paginated array of fully populated `LabelFull` documents.
|
||||
*/
|
||||
async findPageAndPopulate(
|
||||
filters: TFilterQuery<Label>,
|
||||
pageQuery: PageQueryDto<Label>,
|
||||
) {
|
||||
const query = this.findPageQuery(filters, pageQuery).populate(['users']);
|
||||
return await this.execute(query, LabelFull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single label document by its ID and populates the `users` (subscribers) field.
|
||||
*
|
||||
* @param id - The ID of the label to be fetched.
|
||||
*
|
||||
* @returns A promise that resolves with a fully populated label.
|
||||
*/
|
||||
async findOneAndPopulate(id: string) {
|
||||
const query = this.findOneQuery(id).populate(['users']);
|
||||
return await this.executeOne(query, LabelFull);
|
||||
}
|
||||
}
|
||||
102
api/src/chat/repositories/message.repository.spec.ts
Normal file
102
api/src/chat/repositories/message.repository.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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, getModelToken } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Model } from 'mongoose';
|
||||
|
||||
import { UserRepository } from '@/user/repositories/user.repository';
|
||||
import { UserModel } from '@/user/schemas/user.schema';
|
||||
import {
|
||||
installMessageFixtures,
|
||||
messageFixtures,
|
||||
} from '@/utils/test/fixtures/message';
|
||||
import { getPageQuery } from '@/utils/test/pagination';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { MessageRepository } from './message.repository';
|
||||
import { SubscriberRepository } from './subscriber.repository';
|
||||
import { MessageModel, Message } from '../schemas/message.schema';
|
||||
import { SubscriberModel } from '../schemas/subscriber.schema';
|
||||
import { AnyMessage } from '../schemas/types/message';
|
||||
|
||||
describe('MessageRepository', () => {
|
||||
let messageRepository: MessageRepository;
|
||||
let userRepository: UserRepository;
|
||||
let subscriberRepository: SubscriberRepository;
|
||||
let messageModel: Model<Message>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const testModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installMessageFixtures),
|
||||
MongooseModule.forFeature([MessageModel, SubscriberModel, UserModel]),
|
||||
],
|
||||
providers: [
|
||||
MessageRepository,
|
||||
SubscriberRepository,
|
||||
UserRepository,
|
||||
EventEmitter2,
|
||||
],
|
||||
}).compile();
|
||||
messageRepository = testModule.get<MessageRepository>(MessageRepository);
|
||||
userRepository = testModule.get<UserRepository>(UserRepository);
|
||||
subscriberRepository =
|
||||
testModule.get<SubscriberRepository>(SubscriberRepository);
|
||||
messageModel = testModule.get<Model<Message>>(getModelToken('Message'));
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findOneAndPopulate', () => {
|
||||
it('should find one message by id, and populate its sender and recipient', async () => {
|
||||
jest.spyOn(messageModel, 'findById');
|
||||
const message = await messageRepository.findOne({ mid: 'mid-1' });
|
||||
const sender = await subscriberRepository.findOne(message['sender']);
|
||||
const recipient = await subscriberRepository.findOne(
|
||||
message['recipient'],
|
||||
);
|
||||
const user = await userRepository.findOne(message['sentBy']);
|
||||
const result = await messageRepository.findOneAndPopulate(message.id);
|
||||
|
||||
expect(messageModel.findById).toHaveBeenCalledWith(message.id);
|
||||
expect(result).toEqualPayload({
|
||||
...messageFixtures.find(({ mid }) => mid === message.mid),
|
||||
sender,
|
||||
recipient,
|
||||
sentBy: user.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageAndPopulate', () => {
|
||||
it('should find one messages, and foreach message populate its sender and recipient', async () => {
|
||||
jest.spyOn(messageModel, 'find');
|
||||
const pageQuery = getPageQuery<AnyMessage>();
|
||||
const result = await messageRepository.findPageAndPopulate({}, pageQuery);
|
||||
const allSubscribers = await subscriberRepository.findAll();
|
||||
const allUsers = await userRepository.findAll();
|
||||
const allMessages = await messageRepository.findAll();
|
||||
const messages = 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(messageModel.find).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(messages);
|
||||
});
|
||||
});
|
||||
});
|
||||
167
api/src/chat/repositories/message.repository.ts
Normal file
167
api/src/chat/repositories/message.repository.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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, Optional } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { TFilterQuery, Model, Query } from 'mongoose';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { NlpSampleCreateDto } from '@/nlp/dto/nlp-sample.dto';
|
||||
import { NlpSampleState } from '@/nlp/schemas/types';
|
||||
import { NlpSampleService } from '@/nlp/services/nlp-sample.service';
|
||||
import { BaseRepository } from '@/utils/generics/base-repository';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
|
||||
import { Message, MessageFull } from '../schemas/message.schema';
|
||||
import { Subscriber } from '../schemas/subscriber.schema';
|
||||
import { AnyMessage } from '../schemas/types/message';
|
||||
|
||||
@Injectable()
|
||||
export class MessageRepository extends BaseRepository<
|
||||
AnyMessage,
|
||||
'sender' | 'recipient'
|
||||
> {
|
||||
private readonly nlpSampleService: NlpSampleService;
|
||||
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(
|
||||
@InjectModel(Message.name) readonly model: Model<AnyMessage>,
|
||||
@Optional() nlpSampleService?: NlpSampleService,
|
||||
@Optional() logger?: LoggerService,
|
||||
) {
|
||||
super(model, Message as new () => AnyMessage);
|
||||
this.logger = logger;
|
||||
this.nlpSampleService = nlpSampleService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-create hook to validate message data before saving.
|
||||
* If the message is from a end-user (i.e., has a sender), it is saved
|
||||
* as an inbox NLP sample. Throws an error if neither sender nor recipient
|
||||
* is provided.
|
||||
*
|
||||
* @param _doc - The message document to be created.
|
||||
*/
|
||||
async preCreate(_doc: AnyMessage): Promise<void> {
|
||||
if (_doc) {
|
||||
if (!('sender' in _doc) && !('recipient' in _doc)) {
|
||||
this.logger.error('Either sender or recipient must be provided!', _doc);
|
||||
throw new Error('Either sender or recipient must be provided!');
|
||||
}
|
||||
// If message is sent by the user then add it as an inbox sample
|
||||
if (
|
||||
'sender' in _doc &&
|
||||
_doc.sender &&
|
||||
'message' in _doc &&
|
||||
'text' in _doc.message
|
||||
) {
|
||||
const record: NlpSampleCreateDto = {
|
||||
text: _doc.message.text,
|
||||
type: NlpSampleState.inbox,
|
||||
trained: false,
|
||||
};
|
||||
try {
|
||||
await this.nlpSampleService.findOneOrCreate(record, record);
|
||||
this.logger.debug('User message saved as a inbox sample !');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Unable to add message as a new inbox sample!',
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of messages with sender and recipient populated.
|
||||
* Uses filter criteria and pagination settings for the query.
|
||||
*
|
||||
* @param filters - Filter criteria for querying messages.
|
||||
* @param pageQuery - Pagination settings, including skip, limit, and sort order.
|
||||
*
|
||||
* @returns A paginated list of messages with sender and recipient details populated.
|
||||
*/
|
||||
async findPageAndPopulate(
|
||||
filters: TFilterQuery<AnyMessage>,
|
||||
pageQuery: PageQueryDto<AnyMessage>,
|
||||
) {
|
||||
const query = this.findPageQuery(filters, pageQuery).populate([
|
||||
'sender',
|
||||
'recipient',
|
||||
]);
|
||||
|
||||
return await this.execute(
|
||||
query as Query<AnyMessage[], AnyMessage, object, AnyMessage, 'find'>,
|
||||
MessageFull,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single message by its ID, populating the sender and recipient fields.
|
||||
*
|
||||
* @param id - The ID of the message to retrieve.
|
||||
*
|
||||
* @returns The message with sender and recipient details populated.
|
||||
*/
|
||||
async findOneAndPopulate(id: string) {
|
||||
const query = this.findOneQuery(id).populate(['sender', 'recipient']);
|
||||
return await this.executeOne(query, MessageFull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the message history for a given subscriber, with messages sent or received
|
||||
* before the specified date. Results are limited and sorted by creation date.
|
||||
*
|
||||
* @param subscriber - The subscriber whose message history is being retrieved.
|
||||
* @param until - Optional date to retrieve messages sent before (default: current date).
|
||||
* @param limit - Optional limit on the number of messages to retrieve (default: 30).
|
||||
*
|
||||
* @returns The message history until the specified date.
|
||||
*/
|
||||
async findHistoryUntilDate(
|
||||
subscriber: Subscriber,
|
||||
until = new Date(),
|
||||
limit: number = 30,
|
||||
) {
|
||||
return await this.findPage(
|
||||
{
|
||||
$or: [{ recipient: subscriber.id }, { sender: subscriber.id }],
|
||||
createdAt: { $lt: until },
|
||||
},
|
||||
{ skip: 0, limit, sort: ['createdAt', 'desc'] },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the message history for a given subscriber, with messages sent or received
|
||||
* after the specified date. Results are limited and sorted by creation date.
|
||||
*
|
||||
* @param subscriber The subscriber whose message history is being retrieved.
|
||||
* @param since Optional date to retrieve messages sent after (default: current date).
|
||||
* @param limit Optional limit on the number of messages to retrieve (default: 30).
|
||||
*
|
||||
* @returns The message history since the specified date.
|
||||
*/
|
||||
async findHistorySinceDate(
|
||||
subscriber: Subscriber,
|
||||
since = new Date(),
|
||||
limit: number = 30,
|
||||
) {
|
||||
return await this.findPage(
|
||||
{
|
||||
$or: [{ recipient: subscriber.id }, { sender: subscriber.id }],
|
||||
createdAt: { $gt: since },
|
||||
},
|
||||
{ skip: 0, limit, sort: ['createdAt', 'asc'] },
|
||||
);
|
||||
}
|
||||
}
|
||||
154
api/src/chat/repositories/subscriber.repository.spec.ts
Normal file
154
api/src/chat/repositories/subscriber.repository.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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, getModelToken } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Model } from 'mongoose';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentModel,
|
||||
} from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { UserRepository } from '@/user/repositories/user.repository';
|
||||
import { UserModel, User } from '@/user/schemas/user.schema';
|
||||
import {
|
||||
installSubscriberFixtures,
|
||||
subscriberFixtures,
|
||||
} 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 { LabelRepository } from './label.repository';
|
||||
import { SubscriberRepository } from './subscriber.repository';
|
||||
import { LabelModel, Label } from '../schemas/label.schema';
|
||||
import {
|
||||
SubscriberModel,
|
||||
Subscriber,
|
||||
SubscriberFull,
|
||||
} from '../schemas/subscriber.schema';
|
||||
|
||||
describe('SubscriberRepository', () => {
|
||||
let subscriberRepository: SubscriberRepository;
|
||||
let subscriberModel: Model<Subscriber>;
|
||||
let labelRepository: LabelRepository;
|
||||
let userRepository: UserRepository;
|
||||
let attachmentRepository: AttachmentRepository;
|
||||
let allLabels: Label[];
|
||||
let allUsers: User[];
|
||||
let allSubscribers: Subscriber[];
|
||||
let allAttachments: Attachment[];
|
||||
let subscribersWithPopulatedFields: SubscriberFull[];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installSubscriberFixtures),
|
||||
MongooseModule.forFeature([
|
||||
SubscriberModel,
|
||||
LabelModel,
|
||||
UserModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
SubscriberRepository,
|
||||
LabelRepository,
|
||||
UserRepository,
|
||||
EventEmitter2,
|
||||
LoggerService,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
],
|
||||
}).compile();
|
||||
subscriberRepository =
|
||||
module.get<SubscriberRepository>(SubscriberRepository);
|
||||
labelRepository = module.get<LabelRepository>(LabelRepository);
|
||||
userRepository = module.get<UserRepository>(UserRepository);
|
||||
attachmentRepository =
|
||||
module.get<AttachmentRepository>(AttachmentRepository);
|
||||
subscriberModel = module.get<Model<Subscriber>>(
|
||||
getModelToken('Subscriber'),
|
||||
);
|
||||
allLabels = await labelRepository.findAll();
|
||||
allSubscribers = await subscriberRepository.findAll();
|
||||
allUsers = await userRepository.findAll();
|
||||
allAttachments = await attachmentRepository.findAll();
|
||||
subscribersWithPopulatedFields = allSubscribers.map((subscriber) => ({
|
||||
...subscriber,
|
||||
labels: allLabels.filter((label) => subscriber.labels.includes(label.id)),
|
||||
assignedTo:
|
||||
allUsers.find(({ id }) => subscriber.assignedTo === id) || null,
|
||||
avatar: allAttachments.find(({ id }) => subscriber.avatar === id) || null,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findOneAndPopulate', () => {
|
||||
it('should find one subscriber by id,and populate its labels', async () => {
|
||||
jest.spyOn(subscriberModel, 'findById');
|
||||
const subscriber = await subscriberRepository.findOne({
|
||||
first_name: 'Jhon',
|
||||
});
|
||||
const allLabels = await labelRepository.findAll();
|
||||
const result = await subscriberRepository.findOneAndPopulate(
|
||||
subscriber.id,
|
||||
);
|
||||
const subscriberWithLabels = {
|
||||
...subscriberFixtures.find(
|
||||
({ first_name }) => first_name === subscriber.first_name,
|
||||
),
|
||||
labels: allLabels.filter((label) =>
|
||||
subscriber.labels.includes(label.id),
|
||||
),
|
||||
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
|
||||
};
|
||||
|
||||
expect(subscriberModel.findById).toHaveBeenCalledWith(subscriber.id);
|
||||
expect(result).toEqualPayload(subscriberWithLabels);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageAndPopulate', () => {
|
||||
const pageQuery = getPageQuery<Subscriber>();
|
||||
it('should find subscribers, and foreach subscriber populate the corresponding labels', async () => {
|
||||
jest.spyOn(subscriberModel, 'find');
|
||||
const result = await subscriberRepository.findPageAndPopulate(
|
||||
{},
|
||||
pageQuery,
|
||||
);
|
||||
|
||||
expect(subscriberModel.find).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(
|
||||
subscribersWithPopulatedFields.sort(sortRowsBy),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllAndPopulate', () => {
|
||||
it('should return all subscribers, and foreach subscriber populate the corresponding labels', async () => {
|
||||
jest.spyOn(subscriberModel, 'find');
|
||||
const result = await subscriberRepository.findAllAndPopulate();
|
||||
|
||||
expect(subscriberModel.find).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(
|
||||
subscribersWithPopulatedFields.sort(sortRowsBy),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
278
api/src/chat/repositories/subscriber.repository.ts
Normal file
278
api/src/chat/repositories/subscriber.repository.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* 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 { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
Document,
|
||||
Model,
|
||||
Query,
|
||||
TFilterQuery,
|
||||
UpdateQuery,
|
||||
UpdateWithAggregationPipeline,
|
||||
} from 'mongoose';
|
||||
|
||||
import { BaseRepository } from '@/utils/generics/base-repository';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
|
||||
import { SubscriberUpdateDto } from '../dto/subscriber.dto';
|
||||
import {
|
||||
Subscriber,
|
||||
SubscriberDocument,
|
||||
SubscriberFull,
|
||||
} from '../schemas/subscriber.schema';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriberRepository extends BaseRepository<
|
||||
Subscriber,
|
||||
'labels' | 'assignedTo' | 'avatar'
|
||||
> {
|
||||
constructor(
|
||||
@InjectModel(Subscriber.name) readonly model: Model<Subscriber>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {
|
||||
super(model, Subscriber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits events related to the creation of a new subscriber.
|
||||
*
|
||||
* @param _created - The newly created subscriber document.
|
||||
*/
|
||||
async postCreate(_created: SubscriberDocument): Promise<void> {
|
||||
this.eventEmitter.emit(
|
||||
'hook:stats:entry',
|
||||
'new_users',
|
||||
'New users',
|
||||
_created,
|
||||
);
|
||||
this.eventEmitter.emit('hook:chatbot:subscriber:create', _created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits events before updating a subscriber. Specifically handles the
|
||||
* assignment of the subscriber and triggers appropriate events.
|
||||
*
|
||||
* @param _query - The Mongoose query object for finding and updating a subscriber.
|
||||
* @param criteria - The filter criteria used to find the subscriber.
|
||||
* @param updates - The update data, which may include fields like `assignedTo`.
|
||||
*/
|
||||
async preUpdate(
|
||||
_query: Query<
|
||||
Document<Subscriber, any, any>,
|
||||
Document<Subscriber, any, any>,
|
||||
unknown,
|
||||
Subscriber,
|
||||
'findOneAndUpdate'
|
||||
>,
|
||||
criteria: TFilterQuery<Subscriber>,
|
||||
updates:
|
||||
| UpdateWithAggregationPipeline
|
||||
| UpdateQuery<Document<Subscriber, any, any>>,
|
||||
): Promise<void> {
|
||||
const subscriberUpdates: SubscriberUpdateDto = updates?.['$set'];
|
||||
|
||||
this.eventEmitter.emit(
|
||||
'hook:chatbot:subscriber:update:before',
|
||||
criteria,
|
||||
subscriberUpdates,
|
||||
);
|
||||
|
||||
const oldSubscriber = await this.findOne(criteria);
|
||||
|
||||
if (subscriberUpdates.assignedTo !== oldSubscriber?.assignedTo) {
|
||||
this.eventEmitter.emit(
|
||||
'hook:subscriber:assign',
|
||||
subscriberUpdates,
|
||||
oldSubscriber,
|
||||
);
|
||||
|
||||
if (!(subscriberUpdates.assignedTo && oldSubscriber?.assignedTo)) {
|
||||
this.eventEmitter.emit(
|
||||
'hook:analytics:passation',
|
||||
oldSubscriber,
|
||||
!!subscriberUpdates?.assignedTo,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event after successfully updating a subscriber.
|
||||
* Triggers the event with the updated subscriber data.
|
||||
*
|
||||
* @param _query - The Mongoose query object for finding and updating a subscriber.
|
||||
* @param updated - The updated subscriber entity.
|
||||
*/
|
||||
async postUpdate(
|
||||
_query: Query<
|
||||
Document<Subscriber, any, any>,
|
||||
Document<Subscriber, any, any>,
|
||||
unknown,
|
||||
Subscriber,
|
||||
'findOneAndUpdate'
|
||||
>,
|
||||
updated: Subscriber,
|
||||
) {
|
||||
this.eventEmitter.emit('hook:chatbot:subscriber:update:after', updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a query to find a subscriber by their foreign ID.
|
||||
*
|
||||
* @param id - The foreign ID of the subscriber.
|
||||
*
|
||||
* @returns The constructed query object.
|
||||
*/
|
||||
findByForeignIdQuery(id: string) {
|
||||
return this.findPageQuery(
|
||||
{ foreign_id: id },
|
||||
{ skip: 0, limit: 1, sort: ['lastvisit', 'desc'] },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single subscriber by his foreign ID (channel's id).
|
||||
*
|
||||
* @param id - The foreign ID of the subscriber.
|
||||
*
|
||||
* @returns The found subscriber entity.
|
||||
*/
|
||||
async findOneByForeignId(id: string): Promise<Subscriber> {
|
||||
const query = this.findByForeignIdQuery(id);
|
||||
const [result] = await this.execute(query, Subscriber);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a subscriber by their foreign ID and populates related fields such as `labels` and `assignedTo`.
|
||||
*
|
||||
* @param id - The foreign ID of the subscriber.
|
||||
*
|
||||
* @returns The found subscriber entity with populated fields.
|
||||
*/
|
||||
async findOneByForeignIdAndPopulate(id: string): Promise<SubscriberFull> {
|
||||
const query = this.findByForeignIdQuery(id).populate([
|
||||
'labels',
|
||||
'assignedTo',
|
||||
]);
|
||||
const [result] = await this.execute(query, SubscriberFull);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a subscriber's information based on their foreign ID.
|
||||
*
|
||||
* @param id - The foreign ID of the subscriber.
|
||||
* @param updates - The update data to apply to the subscriber.
|
||||
*
|
||||
* @returns The updated subscriber entity.
|
||||
*/
|
||||
async updateOneByForeignIdQuery(
|
||||
id: string,
|
||||
updates: SubscriberUpdateDto,
|
||||
): Promise<Subscriber> {
|
||||
return await this.updateOne({ foreign_id: id }, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassigns a subscriber by their foreign ID by setting the `assignedTo` field to `null`.
|
||||
*
|
||||
* @param foreignId - The foreign ID of the subscriber.
|
||||
*
|
||||
* @returns The updated subscriber entity.
|
||||
*/
|
||||
async handBackByForeignIdQuery(foreignId: string): Promise<Subscriber> {
|
||||
return await this.updateOne(
|
||||
{
|
||||
foreign_id: foreignId,
|
||||
assignedTo: { $ne: null },
|
||||
},
|
||||
{
|
||||
assignedTo: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a subscriber to a new user by their foreign ID.
|
||||
*
|
||||
* @param foreignId The foreign ID of the subscriber.
|
||||
* @param userId The ID of the user to assign the subscriber to.
|
||||
*
|
||||
* @returns The updated subscriber entity.
|
||||
*/
|
||||
async handOverByForeignIdQuery(
|
||||
foreignId: string,
|
||||
userId: string,
|
||||
): Promise<Subscriber> {
|
||||
return await this.updateOne(
|
||||
{
|
||||
foreign_id: foreignId,
|
||||
assignedTo: { $ne: userId },
|
||||
},
|
||||
{
|
||||
assignedTo: userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all subscribers and populates related fields such as `labels`, `assignedTo`, and `avatar`.
|
||||
*
|
||||
* @returns A list of all subscribers with populated fields.
|
||||
*/
|
||||
async findAllAndPopulate(): Promise<SubscriberFull[]> {
|
||||
const query = this.findAllQuery().populate([
|
||||
'labels',
|
||||
'assignedTo',
|
||||
'avatar',
|
||||
]);
|
||||
return await this.execute(query, SubscriberFull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds subscribers using pagination and populates related fields such as `labels`, `assignedTo`, and `avatar`.
|
||||
*
|
||||
* @param filters - The filter criteria to apply when finding subscribers.
|
||||
* @param pageQuery - The pagination query.
|
||||
*
|
||||
* @returns A paginated list of subscribers with populated fields.
|
||||
*/
|
||||
async findPageAndPopulate(
|
||||
filters: TFilterQuery<Subscriber>,
|
||||
pageQuery: PageQueryDto<Subscriber>,
|
||||
): Promise<SubscriberFull[]> {
|
||||
const query = this.findPageQuery(filters, pageQuery).populate([
|
||||
'labels',
|
||||
'assignedTo',
|
||||
'avatar',
|
||||
]);
|
||||
return await this.execute(query, SubscriberFull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single subscriber by criteria and populates related fields such as `labels`, `assignedTo`, and `avatar`.
|
||||
*
|
||||
* @param criteria - The filter criteria to apply when finding a subscriber.
|
||||
*
|
||||
* @returns The found subscriber entity with populated fields.
|
||||
*/
|
||||
async findOneAndPopulate(
|
||||
criteria: string | TFilterQuery<Subscriber>,
|
||||
): Promise<SubscriberFull> {
|
||||
const query = this.findOneQuery(criteria).populate([
|
||||
'labels',
|
||||
'assignedTo',
|
||||
'avatar',
|
||||
]);
|
||||
return await this.executeOne(query, SubscriberFull);
|
||||
}
|
||||
}
|
||||
77
api/src/chat/repositories/translation.repository.ts
Normal file
77
api/src/chat/repositories/translation.repository.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { InjectModel } from '@nestjs/mongoose';
|
||||
import { Document, Model, Query, Types } from 'mongoose';
|
||||
|
||||
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
|
||||
|
||||
import { Translation } from '../schemas/translation.schema';
|
||||
|
||||
@Injectable()
|
||||
export class TranslationRepository extends BaseRepository<Translation> {
|
||||
constructor(
|
||||
@InjectModel(Translation.name) readonly model: Model<Translation>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {
|
||||
super(model, Translation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event after a translation document is updated.
|
||||
*
|
||||
* @param query - The query object representing the update operation.
|
||||
* @param updated - The updated translation document.
|
||||
*/
|
||||
async postUpdate(
|
||||
_query: Query<
|
||||
Document<Translation, any, any>,
|
||||
Document<Translation, any, any>,
|
||||
unknown,
|
||||
Translation,
|
||||
'findOneAndUpdate'
|
||||
>,
|
||||
_updated: Translation,
|
||||
) {
|
||||
this.eventEmitter.emit('hook:translation:update');
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event after a new translation document is created.
|
||||
*
|
||||
* @param created - The newly created translation document.
|
||||
*/
|
||||
async postCreate(
|
||||
_created: Document<unknown, unknown, Translation> &
|
||||
Translation & { _id: Types.ObjectId },
|
||||
) {
|
||||
this.eventEmitter.emit('hook:translation:create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event after a translation document is deleted.
|
||||
*
|
||||
* @param query - The query object representing the delete operation.
|
||||
* @param result - The result of the delete operation.
|
||||
*/
|
||||
async postDelete(
|
||||
_query: Query<
|
||||
DeleteResult,
|
||||
Document<Translation, any, any>,
|
||||
unknown,
|
||||
Translation,
|
||||
'deleteOne' | 'deleteMany'
|
||||
>,
|
||||
_result: DeleteResult,
|
||||
) {
|
||||
this.eventEmitter.emit('hook:translation:delete');
|
||||
}
|
||||
}
|
||||
196
api/src/chat/schemas/block.schema.ts
Normal file
196
api/src/chat/schemas/block.schema.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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 { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { Exclude, Transform, Type } from 'class-transformer';
|
||||
import { Schema as MongooseSchema, THydratedDocument } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
|
||||
|
||||
import { Category } from './category.schema';
|
||||
import { Label } from './label.schema';
|
||||
import { CaptureVar } from './types/capture-var';
|
||||
import { BlockMessage } from './types/message';
|
||||
import { BlockOptions } from './types/options';
|
||||
import { Pattern } from './types/pattern';
|
||||
import { Position } from './types/position';
|
||||
import { isValidMessage } from '../validation-rules/is-message';
|
||||
import { isPatternList } from '../validation-rules/is-pattern-list';
|
||||
import { isPosition } from '../validation-rules/is-position';
|
||||
import { isValidVarCapture } from '../validation-rules/is-valid-capture';
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class BlockStub extends BaseSchema {
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
validate: isPatternList,
|
||||
default: [],
|
||||
})
|
||||
patterns?: Pattern[];
|
||||
|
||||
@Prop([
|
||||
{
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
ref: 'Label',
|
||||
default: [],
|
||||
},
|
||||
])
|
||||
trigger_labels?: unknown;
|
||||
|
||||
@Prop([
|
||||
{
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
ref: 'Label',
|
||||
default: [],
|
||||
},
|
||||
])
|
||||
assign_labels?: unknown;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
default: [],
|
||||
})
|
||||
trigger_channels?: string[];
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
default: {},
|
||||
})
|
||||
options?: BlockOptions;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
validate: isValidMessage,
|
||||
})
|
||||
message: BlockMessage;
|
||||
|
||||
@Prop([
|
||||
{
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
ref: 'Block',
|
||||
default: [],
|
||||
},
|
||||
])
|
||||
nextBlocks?: unknown;
|
||||
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
ref: 'Block',
|
||||
})
|
||||
attachedBlock?: unknown;
|
||||
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
ref: 'Category',
|
||||
})
|
||||
category: unknown;
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
starts_conversation?: boolean;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
validate: isValidVarCapture,
|
||||
default: [],
|
||||
})
|
||||
capture_vars?: CaptureVar[];
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
validate: isPosition,
|
||||
})
|
||||
position: Position;
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
builtin?: boolean;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Block extends BlockStub {
|
||||
@Transform(({ obj }) => obj.trigger_labels?.map((elem) => elem.toString()))
|
||||
trigger_labels?: string[];
|
||||
|
||||
@Transform(({ obj }) => obj.assign_labels?.map((elem) => elem.toString()))
|
||||
assign_labels?: string[];
|
||||
|
||||
@Transform(({ obj }) => obj.nextBlocks?.map((elem) => elem.toString()))
|
||||
nextBlocks?: string[];
|
||||
|
||||
@Transform(({ obj }) => obj.attachedBlock?.toString() || null)
|
||||
attachedBlock?: string;
|
||||
|
||||
@Transform(({ obj }) => obj.category.toString())
|
||||
category: string;
|
||||
|
||||
@Exclude()
|
||||
previousBlocks?: never;
|
||||
|
||||
@Exclude()
|
||||
attachedToBlock?: never | null;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class BlockFull extends BlockStub {
|
||||
@Type(() => Label)
|
||||
trigger_labels: Label[];
|
||||
|
||||
@Type(() => Label)
|
||||
assign_labels: Label[];
|
||||
|
||||
@Type(() => Block)
|
||||
nextBlocks?: Block[];
|
||||
|
||||
@Type(() => Block)
|
||||
attachedBlock?: Block;
|
||||
|
||||
@Type(() => Category)
|
||||
category: Category;
|
||||
|
||||
@Type(() => Block)
|
||||
previousBlocks: Block[];
|
||||
|
||||
@Type(() => Block)
|
||||
attachedToBlock?: Block;
|
||||
}
|
||||
|
||||
export type BlockDocument = THydratedDocument<Block>;
|
||||
|
||||
export const BlockModel: ModelDefinition = LifecycleHookManager.attach({
|
||||
name: Block.name,
|
||||
schema: SchemaFactory.createForClass(BlockStub),
|
||||
});
|
||||
|
||||
BlockModel.schema.virtual('previousBlocks', {
|
||||
ref: 'Block',
|
||||
localField: '_id',
|
||||
foreignField: 'nextBlocks',
|
||||
justOne: false,
|
||||
});
|
||||
|
||||
BlockModel.schema.virtual('attachedToBlock', {
|
||||
ref: 'Block',
|
||||
localField: '_id',
|
||||
foreignField: 'attachedBlock',
|
||||
justOne: true,
|
||||
});
|
||||
|
||||
export default BlockModel.schema;
|
||||
51
api/src/chat/schemas/category.schema.ts
Normal file
51
api/src/chat/schemas/category.schema.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
|
||||
import { THydratedDocument } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Category extends BaseSchema {
|
||||
@Prop({
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true,
|
||||
})
|
||||
label: string;
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
builtin?: boolean;
|
||||
|
||||
@Prop({
|
||||
type: Number,
|
||||
default: 100,
|
||||
})
|
||||
zoom?: number;
|
||||
|
||||
@Prop({
|
||||
type: [Number, Number],
|
||||
default: [0, 0],
|
||||
})
|
||||
offset?: [number, number];
|
||||
}
|
||||
|
||||
export const CategoryModel: ModelDefinition = LifecycleHookManager.attach({
|
||||
name: Category.name,
|
||||
schema: SchemaFactory.createForClass(Category),
|
||||
});
|
||||
|
||||
export type CategoryDocument = THydratedDocument<Category>;
|
||||
|
||||
export default CategoryModel.schema;
|
||||
40
api/src/chat/schemas/context-var.schema.ts
Normal file
40
api/src/chat/schemas/context-var.schema.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
|
||||
import { THydratedDocument } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class ContextVar extends BaseSchema {
|
||||
@Prop({
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true,
|
||||
})
|
||||
label: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true,
|
||||
match: /^[a-z_0-9]+$/,
|
||||
})
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const ContextVarModel: ModelDefinition = {
|
||||
name: ContextVar.name,
|
||||
schema: SchemaFactory.createForClass(ContextVar),
|
||||
};
|
||||
|
||||
export type ContextVarDocument = THydratedDocument<ContextVar>;
|
||||
|
||||
export default ContextVarModel.schema;
|
||||
105
api/src/chat/schemas/conversation.schema.ts
Normal file
105
api/src/chat/schemas/conversation.schema.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { THydratedDocument, Schema as MongooseSchema } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
|
||||
import { Block } from './block.schema';
|
||||
import { Subscriber } from './subscriber.schema';
|
||||
import { Context } from './types/context';
|
||||
|
||||
export function getDefaultConversationContext(): Context {
|
||||
return {
|
||||
vars: {}, // Used for capturing vars from user entries
|
||||
user: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
} as Subscriber,
|
||||
user_location: {
|
||||
// Used for capturing geolocation from QR
|
||||
lat: 0.0,
|
||||
lon: 0.0,
|
||||
},
|
||||
skip: {}, // Used for list pagination
|
||||
attempt: 0, // Used to track fallback max attempts
|
||||
};
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
class ConversationStub extends BaseSchema {
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: 'Subscriber',
|
||||
})
|
||||
sender: unknown;
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
})
|
||||
active?: boolean;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
default: getDefaultConversationContext(),
|
||||
})
|
||||
context?: Context;
|
||||
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
ref: 'Block',
|
||||
})
|
||||
current?: unknown;
|
||||
|
||||
@Prop([
|
||||
{
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
ref: 'Block',
|
||||
default: [],
|
||||
},
|
||||
])
|
||||
next?: unknown;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Conversation extends ConversationStub {
|
||||
@Transform(({ obj }) => obj.sender.toString())
|
||||
sender: string;
|
||||
|
||||
@Transform(({ obj }) => obj.current.toString())
|
||||
current?: string;
|
||||
|
||||
@Transform(({ obj }) => obj.next.map((elem) => elem.toString()))
|
||||
next?: string[];
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class ConversationFull extends ConversationStub {
|
||||
@Type(() => Subscriber)
|
||||
sender: Subscriber;
|
||||
|
||||
@Type(() => Block)
|
||||
current: Block;
|
||||
|
||||
@Type(() => Block)
|
||||
next: Block[];
|
||||
}
|
||||
|
||||
export type ConversationDocument = THydratedDocument<Conversation>;
|
||||
|
||||
export const ConversationModel: ModelDefinition = {
|
||||
name: Conversation.name,
|
||||
schema: SchemaFactory.createForClass(ConversationStub),
|
||||
};
|
||||
|
||||
export default ConversationModel.schema;
|
||||
79
api/src/chat/schemas/label.schema.ts
Normal file
79
api/src/chat/schemas/label.schema.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
|
||||
import { Exclude, Type } from 'class-transformer';
|
||||
import { THydratedDocument } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
|
||||
|
||||
import { Subscriber } from './subscriber.schema';
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class LabelStub extends BaseSchema {
|
||||
@Prop({
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true,
|
||||
})
|
||||
title: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true,
|
||||
match: /^[A-Z_0-9]+$/,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
})
|
||||
label_id?: Record<string, any>; // Indexed by channel name
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
})
|
||||
description?: string;
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
builtin?: boolean;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Label extends LabelStub {
|
||||
@Exclude()
|
||||
users?: never;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class LabelFull extends LabelStub {
|
||||
@Type(() => Subscriber)
|
||||
users?: Subscriber[];
|
||||
}
|
||||
|
||||
export type LabelDocument = THydratedDocument<Label>;
|
||||
|
||||
export const LabelModel: ModelDefinition = LifecycleHookManager.attach({
|
||||
name: Label.name,
|
||||
schema: SchemaFactory.createForClass(LabelStub),
|
||||
});
|
||||
|
||||
LabelModel.schema.virtual('users', {
|
||||
ref: 'Subscriber',
|
||||
localField: '_id',
|
||||
foreignField: 'labels',
|
||||
justOne: false,
|
||||
});
|
||||
|
||||
export default LabelModel.schema;
|
||||
104
api/src/chat/schemas/message.schema.ts
Normal file
104
api/src/chat/schemas/message.schema.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { Schema as MongooseSchema } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
|
||||
|
||||
import { Subscriber } from './subscriber.schema';
|
||||
import { StdIncomingMessage, StdOutgoingMessage } from './types/message';
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class MessageStub extends BaseSchema {
|
||||
@Prop({
|
||||
type: String,
|
||||
required: false,
|
||||
//TODO : add default value for mid
|
||||
})
|
||||
mid?: string;
|
||||
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
required: false,
|
||||
ref: 'Subscriber',
|
||||
})
|
||||
sender?: unknown;
|
||||
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
required: false,
|
||||
ref: 'Subscriber',
|
||||
})
|
||||
recipient?: unknown;
|
||||
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
required: false,
|
||||
ref: 'User',
|
||||
})
|
||||
sentBy?: unknown;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
required: true,
|
||||
})
|
||||
message: StdOutgoingMessage | StdIncomingMessage;
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
read?: boolean;
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
delivery?: boolean;
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
handover?: boolean;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Message extends MessageStub {
|
||||
@Transform(({ obj }) => obj.sender?.toString())
|
||||
sender?: string;
|
||||
|
||||
@Transform(({ obj }) => obj.recipient?.toString())
|
||||
recipient?: string;
|
||||
|
||||
@Transform(({ obj }) => obj.sentBy?.toString())
|
||||
sentBy?: string;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class MessageFull extends MessageStub {
|
||||
@Type(() => Subscriber)
|
||||
sender?: Subscriber;
|
||||
|
||||
@Type(() => Subscriber)
|
||||
recipient?: Subscriber;
|
||||
|
||||
@Transform(({ obj }) => obj.sentBy?.toString())
|
||||
sentBy?: string; // sendBy is never populate
|
||||
}
|
||||
|
||||
export const MessageModel: ModelDefinition = LifecycleHookManager.attach({
|
||||
name: Message.name,
|
||||
schema: SchemaFactory.createForClass(MessageStub),
|
||||
});
|
||||
|
||||
export default MessageModel.schema;
|
||||
142
api/src/chat/schemas/subscriber.schema.ts
Normal file
142
api/src/chat/schemas/subscriber.schema.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { THydratedDocument, Schema as MongooseSchema } from 'mongoose';
|
||||
|
||||
import { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
import { User } from '@/user/schemas/user.schema';
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
|
||||
|
||||
import { Label } from './label.schema';
|
||||
import { ChannelData } from './types/channel';
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class SubscriberStub extends BaseSchema {
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
})
|
||||
first_name: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
})
|
||||
last_name: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
})
|
||||
locale: string;
|
||||
|
||||
@Prop({
|
||||
type: Number,
|
||||
default: 0,
|
||||
})
|
||||
timezone?: number;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
})
|
||||
language: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
})
|
||||
gender: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
})
|
||||
country: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
})
|
||||
foreign_id: string;
|
||||
|
||||
@Prop([
|
||||
{ type: MongooseSchema.Types.ObjectId, required: false, ref: 'Label' },
|
||||
])
|
||||
labels: unknown;
|
||||
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
required: false,
|
||||
ref: 'User',
|
||||
default: null,
|
||||
})
|
||||
assignedTo?: unknown;
|
||||
|
||||
@Prop({
|
||||
type: Date,
|
||||
default: null,
|
||||
})
|
||||
assignedAt?: Date;
|
||||
|
||||
@Prop({
|
||||
type: Date,
|
||||
default: () => Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
})
|
||||
lastvisit?: Date;
|
||||
|
||||
@Prop({
|
||||
type: Date,
|
||||
default: () => Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
})
|
||||
retainedFrom?: Date;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
})
|
||||
channel: ChannelData;
|
||||
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
ref: 'Attachment',
|
||||
default: null,
|
||||
})
|
||||
avatar?: unknown;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Subscriber extends SubscriberStub {
|
||||
@Transform(({ obj }) => obj.labels.map((label) => label.toString()))
|
||||
labels: string[];
|
||||
|
||||
@Transform(({ obj }) => (obj.assignedTo ? obj.assignedTo.toString() : null))
|
||||
assignedTo?: string;
|
||||
|
||||
@Transform(({ obj }) => obj.avatar?.toString() || null)
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class SubscriberFull extends SubscriberStub {
|
||||
@Type(() => Label)
|
||||
labels: Label[];
|
||||
|
||||
@Type(() => User)
|
||||
assignedTo?: User | null;
|
||||
|
||||
@Type(() => Attachment)
|
||||
avatar: Attachment | null;
|
||||
}
|
||||
|
||||
export type SubscriberDocument = THydratedDocument<Subscriber>;
|
||||
|
||||
export const SubscriberModel: ModelDefinition = LifecycleHookManager.attach({
|
||||
name: Subscriber.name,
|
||||
schema: SchemaFactory.createForClass(SubscriberStub),
|
||||
});
|
||||
|
||||
export default SubscriberModel.schema;
|
||||
43
api/src/chat/schemas/translation.schema.ts
Normal file
43
api/src/chat/schemas/translation.schema.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
|
||||
import { THydratedDocument } from 'mongoose';
|
||||
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Translation extends BaseSchema {
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
})
|
||||
str: string;
|
||||
|
||||
@Prop({
|
||||
type: Object,
|
||||
required: true,
|
||||
})
|
||||
translations: Record<string, string>;
|
||||
|
||||
@Prop({
|
||||
type: Number,
|
||||
})
|
||||
translated: number;
|
||||
}
|
||||
|
||||
export const TranslationModel: ModelDefinition = {
|
||||
name: Translation.name,
|
||||
schema: SchemaFactory.createForClass(Translation),
|
||||
};
|
||||
|
||||
export type TranslationDocument = THydratedDocument<Translation>;
|
||||
|
||||
export default TranslationModel.schema;
|
||||
39
api/src/chat/schemas/types/attachment.ts
Normal file
39
api/src/chat/schemas/types/attachment.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
|
||||
export enum FileType {
|
||||
image = 'image',
|
||||
video = 'video',
|
||||
audio = 'audio',
|
||||
file = 'file',
|
||||
unknown = 'unknown',
|
||||
}
|
||||
|
||||
export type AttachmentForeignKey = {
|
||||
url?: string;
|
||||
attachment_id: string;
|
||||
};
|
||||
|
||||
export type WithUrl<A> = A & { url?: string };
|
||||
|
||||
export interface AttachmentPayload<
|
||||
A extends WithUrl<Attachment> | AttachmentForeignKey,
|
||||
> {
|
||||
type: FileType;
|
||||
payload: A;
|
||||
}
|
||||
|
||||
export interface IncomingAttachmentPayload {
|
||||
type: FileType;
|
||||
payload: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
29
api/src/chat/schemas/types/button.ts
Normal file
29
api/src/chat/schemas/types/button.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export enum ButtonType {
|
||||
postback = 'postback',
|
||||
web_url = 'web_url',
|
||||
}
|
||||
|
||||
export type PostBackButton = {
|
||||
type: ButtonType.postback;
|
||||
title: string;
|
||||
payload: string;
|
||||
};
|
||||
|
||||
export type WebUrlButton = {
|
||||
type: ButtonType.web_url;
|
||||
title: string;
|
||||
url: string;
|
||||
messenger_extensions?: boolean;
|
||||
webview_height_ratio?: 'compact' | 'tall' | 'full';
|
||||
};
|
||||
|
||||
export type Button = PostBackButton | WebUrlButton;
|
||||
16
api/src/chat/schemas/types/capture-var.ts
Normal file
16
api/src/chat/schemas/types/capture-var.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface CaptureVar {
|
||||
// entity=`-1` to match text message
|
||||
// entity=`-2` for postback payload
|
||||
// entity is `String` for NLP entities
|
||||
entity: number | string;
|
||||
context_var: string;
|
||||
}
|
||||
16
api/src/chat/schemas/types/channel.ts
Normal file
16
api/src/chat/schemas/types/channel.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface BaseChannelData {
|
||||
name: string; // channel name
|
||||
isSocket?: boolean;
|
||||
type?: any; //TODO: type has to be checked
|
||||
}
|
||||
|
||||
export type ChannelData = BaseChannelData;
|
||||
29
api/src/chat/schemas/types/context.ts
Normal file
29
api/src/chat/schemas/types/context.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 { Nlp } from '@/nlp/lib/types';
|
||||
|
||||
import { Payload } from './quick-reply';
|
||||
import { Subscriber } from '../subscriber.schema';
|
||||
|
||||
export interface Context {
|
||||
channel?: string;
|
||||
text?: string;
|
||||
payload?: Payload | string;
|
||||
nlp?: Nlp.ParseEntities | null;
|
||||
vars: { [key: string]: any };
|
||||
user_location: {
|
||||
address?: Record<string, string>;
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
user: Subscriber;
|
||||
skip: Record<string, number>;
|
||||
attempt: number;
|
||||
}
|
||||
196
api/src/chat/schemas/types/message.ts
Normal file
196
api/src/chat/schemas/types/message.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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 { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
import { Content } from '@/cms/schemas/content.schema';
|
||||
|
||||
import {
|
||||
AttachmentForeignKey,
|
||||
AttachmentPayload,
|
||||
IncomingAttachmentPayload,
|
||||
WithUrl,
|
||||
} from './attachment';
|
||||
import { Button } from './button';
|
||||
import { ContentOptions } from './options';
|
||||
import { StdQuickReply } from './quick-reply';
|
||||
import { Message } from '../message.schema';
|
||||
|
||||
/**
|
||||
* StdEventType enum is declared, and currently not used
|
||||
**/
|
||||
|
||||
export enum StdEventType {
|
||||
message = 'message',
|
||||
delivery = 'delivery',
|
||||
read = 'read',
|
||||
typing = 'typing',
|
||||
follow = 'follow',
|
||||
echo = 'echo',
|
||||
unknown = '',
|
||||
}
|
||||
|
||||
/**
|
||||
* IncomingMessageType enum is declared, and currently not used
|
||||
**/
|
||||
export enum IncomingMessageType {
|
||||
message = 'message',
|
||||
postback = 'postback',
|
||||
quick_reply = 'quick_reply',
|
||||
location = 'location',
|
||||
attachments = 'attachments',
|
||||
unknown = '',
|
||||
}
|
||||
|
||||
export enum OutgoingMessageFormat {
|
||||
text = 'text',
|
||||
quickReplies = 'quickReplies',
|
||||
buttons = 'buttons',
|
||||
attachment = 'attachment',
|
||||
list = 'list',
|
||||
carousel = 'carousel',
|
||||
}
|
||||
|
||||
/**
|
||||
* FileType enum is declared, and currently not used
|
||||
**/
|
||||
export enum FileType {
|
||||
image = 'image',
|
||||
video = 'video',
|
||||
audio = 'audio',
|
||||
file = 'file',
|
||||
unknown = 'unknown',
|
||||
}
|
||||
|
||||
export enum PayloadType {
|
||||
location = 'location',
|
||||
attachments = 'attachments',
|
||||
}
|
||||
|
||||
export type StdOutgoingTextMessage = { text: string };
|
||||
|
||||
export type StdOutgoingQuickRepliesMessage = {
|
||||
text: string;
|
||||
quickReplies: StdQuickReply[];
|
||||
};
|
||||
|
||||
export type StdOutgoingButtonsMessage = {
|
||||
text: string;
|
||||
buttons: Button[];
|
||||
};
|
||||
|
||||
export type StdOutgoingListMessage = {
|
||||
options: ContentOptions;
|
||||
elements: Content[];
|
||||
pagination: {
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type StdOutgoingAttachmentMessage<
|
||||
A extends WithUrl<Attachment> | AttachmentForeignKey,
|
||||
> = {
|
||||
// Stored in DB as `AttachmentPayload`, `Attachment` when populated for channels relaying
|
||||
attachment: AttachmentPayload<A>;
|
||||
quickReplies?: StdQuickReply[];
|
||||
};
|
||||
|
||||
export type StdPluginMessage = {
|
||||
plugin: string;
|
||||
args: { [key: string]: any };
|
||||
};
|
||||
|
||||
export type BlockMessage =
|
||||
| string[]
|
||||
| StdOutgoingTextMessage
|
||||
| StdOutgoingQuickRepliesMessage
|
||||
| StdOutgoingButtonsMessage
|
||||
| StdOutgoingListMessage
|
||||
| StdOutgoingAttachmentMessage<AttachmentForeignKey>
|
||||
| StdPluginMessage;
|
||||
|
||||
export type StdOutgoingMessage =
|
||||
| StdOutgoingTextMessage
|
||||
| StdOutgoingQuickRepliesMessage
|
||||
| StdOutgoingButtonsMessage
|
||||
| StdOutgoingListMessage
|
||||
| StdOutgoingAttachmentMessage<WithUrl<Attachment>>;
|
||||
|
||||
type StdIncomingTextMessage = { text: string };
|
||||
|
||||
export type StdIncomingPostBackMessage = StdIncomingTextMessage & {
|
||||
postback: string;
|
||||
};
|
||||
|
||||
export type StdIncomingLocationMessage = {
|
||||
type: PayloadType.location;
|
||||
coordinates: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type StdIncomingAttachmentMessage = {
|
||||
type: PayloadType.attachments;
|
||||
serialized_text: string;
|
||||
attachment: IncomingAttachmentPayload | IncomingAttachmentPayload[];
|
||||
};
|
||||
|
||||
export type StdIncomingMessage =
|
||||
| StdIncomingTextMessage
|
||||
| StdIncomingPostBackMessage
|
||||
| StdIncomingLocationMessage
|
||||
| StdIncomingAttachmentMessage;
|
||||
|
||||
export interface IncomingMessage extends Omit<Message, 'recipient' | 'sentBy'> {
|
||||
message: StdIncomingMessage;
|
||||
sender: string;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage extends Omit<Message, 'sender'> {
|
||||
message: StdOutgoingMessage;
|
||||
recipient: string;
|
||||
sentBy?: string;
|
||||
handover?: boolean;
|
||||
}
|
||||
|
||||
export type AnyMessage = IncomingMessage | OutgoingMessage;
|
||||
|
||||
export interface StdOutgoingTextEnvelope {
|
||||
format: OutgoingMessageFormat.text;
|
||||
message: StdOutgoingTextMessage;
|
||||
}
|
||||
|
||||
export interface StdOutgoingQuickRepliesEnvelope {
|
||||
format: OutgoingMessageFormat.quickReplies;
|
||||
message: StdOutgoingQuickRepliesMessage;
|
||||
}
|
||||
|
||||
export interface StdOutgoingButtonsEnvelope {
|
||||
format: OutgoingMessageFormat.buttons;
|
||||
message: StdOutgoingButtonsMessage;
|
||||
}
|
||||
|
||||
export interface StdOutgoingListEnvelope {
|
||||
format: OutgoingMessageFormat.list | OutgoingMessageFormat.carousel;
|
||||
message: StdOutgoingListMessage;
|
||||
}
|
||||
|
||||
export interface StdOutgoingAttachmentEnvelope {
|
||||
format: OutgoingMessageFormat.attachment;
|
||||
message: StdOutgoingAttachmentMessage<WithUrl<Attachment>>;
|
||||
}
|
||||
|
||||
export type StdOutgoingEnvelope =
|
||||
| StdOutgoingTextEnvelope
|
||||
| StdOutgoingQuickRepliesEnvelope
|
||||
| StdOutgoingButtonsEnvelope
|
||||
| StdOutgoingListEnvelope
|
||||
| StdOutgoingAttachmentEnvelope;
|
||||
43
api/src/chat/schemas/types/options.ts
Normal file
43
api/src/chat/schemas/types/options.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { Button } from './button';
|
||||
import { OutgoingMessageFormat } from './message';
|
||||
|
||||
export interface ContentOptions {
|
||||
display: OutgoingMessageFormat.list | OutgoingMessageFormat.carousel;
|
||||
fields: {
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
image_url: string | null;
|
||||
url?: string;
|
||||
action_title?: string;
|
||||
action_payload?: string;
|
||||
};
|
||||
buttons: Button[];
|
||||
limit: number;
|
||||
query?: any; // Waterline model criteria
|
||||
entity?: string | number; // ContentTypeID
|
||||
top_element_style?: 'large' | 'compact';
|
||||
}
|
||||
|
||||
export interface BlockOptions {
|
||||
typing?: number;
|
||||
// In case of carousel/list message
|
||||
content?: ContentOptions;
|
||||
// Only if the block has next blocks
|
||||
fallback?: {
|
||||
active: boolean;
|
||||
message: string[];
|
||||
max_attempts: number;
|
||||
};
|
||||
assignTo?: string;
|
||||
// plugins effects
|
||||
effects?: string[];
|
||||
}
|
||||
30
api/src/chat/schemas/types/pattern.ts
Normal file
30
api/src/chat/schemas/types/pattern.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 { PayloadType } from './message';
|
||||
|
||||
export interface PayloadPattern {
|
||||
label: string;
|
||||
value: string;
|
||||
// @todo : rename 'attachment' to 'attachments'
|
||||
type?: PayloadType;
|
||||
}
|
||||
|
||||
export type NlpPattern =
|
||||
| {
|
||||
entity: string;
|
||||
match: 'entity';
|
||||
}
|
||||
| {
|
||||
entity: string;
|
||||
match: 'value';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Pattern = string | RegExp | PayloadPattern | NlpPattern[];
|
||||
13
api/src/chat/schemas/types/position.ts
Normal file
13
api/src/chat/schemas/types/position.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
41
api/src/chat/schemas/types/quick-reply.ts
Normal file
41
api/src/chat/schemas/types/quick-reply.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { IncomingAttachmentPayload } from './attachment';
|
||||
|
||||
export enum PayloadType {
|
||||
location = 'location',
|
||||
attachments = 'attachments',
|
||||
}
|
||||
|
||||
export type Payload =
|
||||
| {
|
||||
type: PayloadType.location;
|
||||
coordinates: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: PayloadType.attachments;
|
||||
attachments: IncomingAttachmentPayload;
|
||||
};
|
||||
|
||||
export enum QuickReplyType {
|
||||
text = 'text',
|
||||
location = 'location',
|
||||
user_phone_number = 'user_phone_number',
|
||||
user_email = 'user_email',
|
||||
}
|
||||
|
||||
export interface StdQuickReply {
|
||||
content_type: QuickReplyType;
|
||||
title: string;
|
||||
payload: string;
|
||||
}
|
||||
19
api/src/chat/seeds/category.seed-model.ts
Normal file
19
api/src/chat/seeds/category.seed-model.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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 { CategoryCreateDto } from '../dto/category.dto';
|
||||
|
||||
export const categoryModels: CategoryCreateDto[] = [
|
||||
{
|
||||
label: 'category1',
|
||||
},
|
||||
{
|
||||
label: 'category2',
|
||||
},
|
||||
];
|
||||
22
api/src/chat/seeds/category.seed.ts
Normal file
22
api/src/chat/seeds/category.seed.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 { BaseSeeder } from '@/utils/generics/base-seeder';
|
||||
|
||||
import { CategoryRepository } from '../repositories/category.repository';
|
||||
import { Category } from '../schemas/category.schema';
|
||||
|
||||
@Injectable()
|
||||
export class CategorySeeder extends BaseSeeder<Category> {
|
||||
constructor(private readonly categoryRepository: CategoryRepository) {
|
||||
super(categoryRepository);
|
||||
}
|
||||
}
|
||||
21
api/src/chat/seeds/context-var.seed-model.ts
Normal file
21
api/src/chat/seeds/context-var.seed-model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 { ContextVarCreateDto } from '../dto/context-var.dto';
|
||||
|
||||
export const contextVarModels: ContextVarCreateDto[] = [
|
||||
{
|
||||
name: 'phone_number',
|
||||
label: 'Phone Number',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email Address',
|
||||
},
|
||||
];
|
||||
22
api/src/chat/seeds/context-var.seed.ts
Normal file
22
api/src/chat/seeds/context-var.seed.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 { BaseSeeder } from '@/utils/generics/base-seeder';
|
||||
|
||||
import { ContextVarRepository } from '../repositories/context-var.repository';
|
||||
import { ContextVar } from '../schemas/context-var.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ContextVarSeeder extends BaseSeeder<ContextVar> {
|
||||
constructor(private readonly contextVarRepository: ContextVarRepository) {
|
||||
super(contextVarRepository);
|
||||
}
|
||||
}
|
||||
21
api/src/chat/seeds/translation.seed-model.ts
Normal file
21
api/src/chat/seeds/translation.seed-model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 { TranslationCreateDto } from '../dto/translation.dto';
|
||||
|
||||
export const translationModels: TranslationCreateDto[] = [
|
||||
{
|
||||
str: 'Welcome',
|
||||
translations: {
|
||||
en: 'Welcome',
|
||||
fr: 'Bienvenue',
|
||||
},
|
||||
translated: 100,
|
||||
},
|
||||
];
|
||||
22
api/src/chat/seeds/translation.seed.ts
Normal file
22
api/src/chat/seeds/translation.seed.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 { BaseSeeder } from '@/utils/generics/base-seeder';
|
||||
|
||||
import { TranslationRepository } from '../repositories/translation.repository';
|
||||
import { Translation } from '../schemas/translation.schema';
|
||||
|
||||
@Injectable()
|
||||
export class TranslationSeeder extends BaseSeeder<Translation> {
|
||||
constructor(private readonly translationRepository: TranslationRepository) {
|
||||
super(translationRepository);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user