Merge pull request #547 from Hexastack/545-issue-chat-module-strictnullchecks-issues
Some checks are pending
Build and Push Docker API Image / build-and-push (push) Waiting to run
Build and Push Docker Base Image / build-and-push (push) Waiting to run
Build and Push Docker UI Image / build-and-push (push) Waiting to run

fix: apply strict null checks updates to the Chat Module
This commit is contained in:
Med Marrouchi 2025-01-15 11:53:47 +01:00 committed by GitHub
commit 0211153a82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 288 additions and 194 deletions

View File

@ -162,8 +162,8 @@ export class ChannelService {
},
{
foreign_id: req.session.passport.user.id,
first_name: req.session.passport.user.first_name,
last_name: req.session.passport.user.last_name,
first_name: req.session.passport.user.first_name || 'Anonymous',
last_name: req.session.passport.user.last_name || 'Anonymous',
locale: '',
language: '',
gender: '',

View File

@ -28,6 +28,8 @@ export const subscriberInstance: Subscriber = {
name: 'web-channel',
},
labels: [],
avatar: null,
context: {},
...modelInstance,
};

View File

@ -142,18 +142,19 @@ describe('BlockController', () => {
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({
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({
}))!;
hasPreviousBlocks = (await blockService.findOne({
name: 'hasPreviousBlocks',
});
}))!;
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('find', () => {
@ -185,6 +186,7 @@ describe('BlockController', () => {
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
attachedToBlock: null,
}));
expect(blockService.findAndPopulate).toHaveBeenCalledWith({}, undefined);
@ -220,12 +222,13 @@ describe('BlockController', () => {
...blockFixtures.find(({ name }) => name === 'hasPreviousBlocks'),
category,
previousBlocks: [hasNextBlocks],
attachedToBlock: null,
});
});
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' });
block = (await blockService.findOne({ name: 'attachment' }))!;
const result = await blockController.findOne(
block.id,
FIELDS_TO_POPULATE,
@ -235,6 +238,7 @@ describe('BlockController', () => {
...blockFixtures.find(({ name }) => name === 'attachment'),
category,
previousBlocks: [],
attachedToBlock: null,
});
});
});

View File

@ -219,9 +219,12 @@ export class BlockController extends BaseController<
this.validate({
dto: block,
allowedIds: {
category: (await this.categoryService.findOne(block.category))?.id,
attachedBlock: (await this.blockService.findOne(block.attachedBlock))
?.id,
category: block.category
? (await this.categoryService.findOne(block.category))?.id
: null,
attachedBlock: block.attachedBlock
? (await this.blockService.findOne(block.attachedBlock))?.id
: null,
nextBlocks: (
await this.blockService.find({
_id: {

View File

@ -56,15 +56,16 @@ describe('ContextVarController', () => {
contextVarController =
module.get<ContextVarController>(ContextVarController);
contextVarService = module.get<ContextVarService>(ContextVarService);
contextVar = await contextVarService.findOne({
contextVar = (await contextVarService.findOne({
label: 'test context var 1',
});
contextVarToDelete = await contextVarService.findOne({
}))!;
contextVarToDelete = (await contextVarService.findOne({
label: 'test context var 2',
});
}))!;
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
@ -95,7 +96,7 @@ describe('ContextVarController', () => {
expect(contextVarService.findOne).toHaveBeenCalledWith(contextVar.id);
expect(result).toEqualPayload(
contextVarFixtures.find(({ label }) => label === contextVar.label),
contextVarFixtures.find(({ label }) => label === contextVar.label)!,
);
});
});

View File

@ -128,16 +128,17 @@ describe('MessageController', () => {
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);
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', () => {
@ -189,10 +190,10 @@ describe('MessageController', () => {
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,
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.find).toHaveBeenCalledWith({}, pageQuery);
@ -208,9 +209,9 @@ describe('MessageController', () => {
);
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,
sender: allSubscribers.find(({ id }) => id === message.sender),
recipient: allSubscribers.find(({ id }) => id === message.recipient),
sentBy: allUsers.find(({ id }) => id === message.sentBy)?.id,
}));
expect(messageService.findAndPopulate).toHaveBeenCalledWith(

View File

@ -90,15 +90,16 @@ describe('SubscriberController', () => {
subscriberController =
module.get<SubscriberController>(SubscriberController);
subscriber = await subscriberService.findOne({
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', () => {
@ -125,7 +126,7 @@ describe('SubscriberController', () => {
({ first_name }) => first_name === subscriber.first_name,
),
labels: labelIDs,
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id).id,
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id)?.id,
});
});

View File

@ -145,7 +145,9 @@ export class SubscriberController extends BaseController<
* @returns A streamable file containing the avatar image.
*/
@Get(':id/profile_pic')
async getAvatar(@Param('id') id: string): Promise<StreamableFile> {
async getAvatar(
@Param('id') id: string,
): Promise<StreamableFile | undefined> {
const subscriber = await this.subscriberService.findOneAndPopulate(id);
if (!subscriber) {

View File

@ -84,13 +84,13 @@ export class BlockCreateDto {
@IsObjectId({
message: 'Attached block must be a valid objectId',
})
attachedBlock?: string;
attachedBlock?: string | null;
@ApiProperty({ description: 'Block category', type: String })
@IsNotEmpty()
@IsString()
@IsObjectId({ message: 'Category must be a valid objectId' })
category: string;
category: string | null;
@ApiPropertyOptional({
description: 'Block has started conversation',

View File

@ -51,13 +51,13 @@ describe('BlockRepository', () => {
validIds = ['64abc1234def567890fedcba', '64abc1234def567890fedcbc'];
validCategory = '64def5678abc123490fedcba';
category = await categoryRepository.findOne({ label: 'default' });
hasPreviousBlocks = await blockRepository.findOne({
category = (await categoryRepository.findOne({ label: 'default' }))!;
hasPreviousBlocks = (await blockRepository.findOne({
name: 'hasPreviousBlocks',
});
hasNextBlocks = await blockRepository.findOne({
}))!;
hasNextBlocks = (await blockRepository.findOne({
name: 'hasNextBlocks',
});
}))!;
});
afterEach(jest.clearAllMocks);
@ -77,6 +77,7 @@ describe('BlockRepository', () => {
category,
nextBlocks: [hasPreviousBlocks],
previousBlocks: [],
attachedToBlock: null,
});
});
});
@ -93,6 +94,7 @@ describe('BlockRepository', () => {
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
attachedToBlock: null,
}));
expect(blockModel.find).toHaveBeenCalledWith({}, undefined);
@ -110,6 +112,7 @@ describe('BlockRepository', () => {
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
attachedToBlock: null,
}));
expect(blockModel.find).toHaveBeenCalledWith({}, undefined);
@ -191,7 +194,7 @@ describe('BlockRepository', () => {
category: validCategory,
nextBlocks: [],
attachedBlock: null,
} as Block);
} as unknown as Block);
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
@ -233,7 +236,7 @@ describe('BlockRepository', () => {
attachedBlock: null,
nextBlocks: [validIds[0], validIds[1]],
},
] as Block[];
] as unknown as Block[];
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');

View File

@ -38,7 +38,7 @@ export class ContextVarRepository extends BaseRepository<
@Optional() blockService?: BlockService,
) {
super(eventEmitter, model, ContextVar);
this.blockService = blockService;
if (blockService) this.blockService = blockService;
}
/**

View File

@ -62,12 +62,12 @@ describe('MessageRepository', () => {
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 message = (await messageRepository.findOne({ mid: 'mid-1' }))!;
const sender = await subscriberRepository.findOne(message!['sender']);
const recipient = await subscriberRepository.findOne(
message['recipient'],
message!['recipient'],
);
const user = await userRepository.findOne(message['sentBy']);
const user = (await userRepository.findOne(message!['sentBy']))!;
const result = await messageRepository.findOneAndPopulate(message.id);
expect(messageModel.findById).toHaveBeenCalledWith(message.id, undefined);
@ -92,7 +92,7 @@ describe('MessageRepository', () => {
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']),
recipient: allSubscribers.find(({ id }) => id === message['recipient']),
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
sentBy: allUsers.find(({ id }) => id === message['sentBy'])?.id,
}));
expect(messageModel.find).toHaveBeenCalledWith({}, undefined);

View File

@ -97,14 +97,15 @@ describe('SubscriberRepository', () => {
});
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({
const subscriber = (await subscriberRepository.findOne({
first_name: 'Jhon',
});
}))!;
const allLabels = await labelRepository.findAll();
const result = await subscriberRepository.findOneAndPopulate(
subscriber.id,

View File

@ -135,7 +135,7 @@ export class SubscriberRepository extends BaseRepository<
*/
async findOneByForeignIdAndPopulate(id: string): Promise<SubscriberFull> {
const query = this.findByForeignIdQuery(id).populate(this.populate);
const [result] = await this.execute(query, this.clsPopulate);
const [result] = await this.execute(query, SubscriberFull);
return result;
}
@ -150,7 +150,7 @@ export class SubscriberRepository extends BaseRepository<
async updateOneByForeignIdQuery(
id: string,
updates: SubscriberUpdateDto,
): Promise<Subscriber> {
): Promise<Subscriber | null> {
return await this.updateOne({ foreign_id: id }, updates);
}
@ -161,7 +161,9 @@ export class SubscriberRepository extends BaseRepository<
*
* @returns The updated subscriber entity.
*/
async handBackByForeignIdQuery(foreignId: string): Promise<Subscriber> {
async handBackByForeignIdQuery(
foreignId: string,
): Promise<Subscriber | null> {
return await this.updateOne(
{
foreign_id: foreignId,
@ -184,7 +186,7 @@ export class SubscriberRepository extends BaseRepository<
async handOverByForeignIdQuery(
foreignId: string,
userId: string,
): Promise<Subscriber> {
): Promise<Subscriber | null> {
return await this.updateOne(
{
foreign_id: foreignId,

View File

@ -139,17 +139,19 @@ export class Block extends BlockStub {
@Transform(({ obj }) => obj.nextBlocks.map((elem) => elem.toString()))
nextBlocks: string[];
@Transform(({ obj }) => obj.attachedBlock?.toString() || null)
attachedBlock: string;
@Transform(({ obj }) =>
obj.attachedBlock ? obj.attachedBlock.toString() : null,
)
attachedBlock: string | null;
@Transform(({ obj }) => obj.category.toString())
category: string;
@Transform(({ obj }) => (obj.category ? obj.category.toString() : null))
category: string | null;
@Exclude()
previousBlocks?: never;
@Exclude()
attachedToBlock?: never | null;
attachedToBlock?: never;
}
@Schema({ timestamps: true })
@ -164,10 +166,10 @@ export class BlockFull extends BlockStub {
nextBlocks: Block[];
@Type(() => Block)
attachedBlock: Block;
attachedBlock: Block | null;
@Type(() => Category)
category: Category;
category: Category | null;
@Type(() => Block)
previousBlocks?: Block[];

View File

@ -80,13 +80,13 @@ export class SubscriberStub extends BaseSchema {
ref: 'User',
default: null,
})
assignedTo?: unknown;
assignedTo: unknown;
@Prop({
type: Date,
default: null,
})
assignedAt?: Date;
assignedAt: Date | null;
@Prop({
type: Date,
@ -110,13 +110,13 @@ export class SubscriberStub extends BaseSchema {
ref: 'Attachment',
default: null,
})
avatar?: unknown;
avatar: unknown;
@Prop({
type: Object,
default: { vars: {} },
})
context?: SubscriberContext;
context: SubscriberContext;
static getChannelData<
C extends ChannelName,
@ -131,11 +131,11 @@ 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.assignedTo?.toString() || null)
assignedTo: string | null;
@Transform(({ obj }) => obj.avatar?.toString() || null)
avatar?: string;
avatar: string | null;
}
@Schema({ timestamps: true })
@ -144,7 +144,7 @@ export class SubscriberFull extends SubscriberStub {
labels: Label[];
@Type(() => User)
assignedTo?: User | null;
assignedTo: User | null;
@Type(() => Attachment)
avatar: Attachment | null;

View File

@ -8,14 +8,12 @@
import { ChannelName } from '@/channel/types';
export type SubscriberChannelData<
C extends ChannelName = null,
// K extends keyof SubscriberChannelDict[C] = keyof SubscriberChannelDict[C],
> = C extends null
? { name: ChannelName }
: {
name: C;
} & {
// Channel's specific attributes
[P in keyof SubscriberChannelDict[C]]: SubscriberChannelDict[C][P];
};
export type SubscriberChannelData<C extends ChannelName = 'unknown-channel'> =
C extends 'unknown-channel'
? { name: ChannelName }
: {
name: C;
} & {
// Channel's specific attributes
[P in keyof SubscriberChannelDict[C]]: SubscriberChannelDict[C][P];
};

View File

@ -154,11 +154,11 @@ describe('BlockService', () => {
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({
category = (await categoryRepository.findOne({ label: 'default' }))!;
hasPreviousBlocks = (await blockRepository.findOne({
name: 'hasPreviousBlocks',
});
block = await blockRepository.findOne({ name: 'hasNextBlocks' });
}))!;
block = (await blockRepository.findOne({ name: 'hasNextBlocks' }))!;
settings = await settingService.getSettings();
});
@ -179,6 +179,7 @@ describe('BlockService', () => {
category,
nextBlocks: [hasPreviousBlocks],
previousBlocks: [],
attachedToBlock: null,
});
});
});
@ -194,6 +195,7 @@ describe('BlockService', () => {
blockFixture.name === 'hasPreviousBlocks' ? [block] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
attachedToBlock: null,
}));
expect(blockRepository.findAndPopulate).toHaveBeenCalledWith(
@ -380,7 +382,7 @@ describe('BlockService', () => {
},
blockGetStarted,
);
expect(result).toEqual(blockGetStarted.patterns[3]);
expect(result).toEqual(blockGetStarted.patterns?.[3]);
});
it("should match payload when it's an attachment file", () => {
@ -397,7 +399,7 @@ describe('BlockService', () => {
},
blockGetStarted,
);
expect(result).toEqual(blockGetStarted.patterns[4]);
expect(result).toEqual(blockGetStarted.patterns?.[4]);
});
});
@ -439,8 +441,10 @@ describe('BlockService', () => {
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 contentType = (await contentTypeService.findOne({
name: 'Product',
}))!;
blockProductListMock.options.content!.entity = contentType.id;
const result = await blockService.processMessage(
blockProductListMock,
{
@ -457,13 +461,13 @@ describe('BlockService', () => {
);
const flattenedElements = elements.map(Content.toElement);
expect(result.format).toEqualPayload(
blockProductListMock.options.content?.display,
blockProductListMock.options.content!.display,
);
expect(
(result.message as StdOutgoingListMessage).elements,
).toEqualPayload(flattenedElements);
expect((result.message as StdOutgoingListMessage).options).toEqualPayload(
blockProductListMock.options.content,
blockProductListMock.options.content!,
);
expect(
(result.message as StdOutgoingListMessage).pagination,
@ -471,8 +475,10 @@ describe('BlockService', () => {
});
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 contentType = (await contentTypeService.findOne({
name: 'Product',
}))!;
blockProductListMock.options.content!.entity = contentType.id;
const result = await blockService.processMessage(
blockProductListMock,
{

View File

@ -440,7 +440,7 @@ export class BlockService extends BaseService<
subscriberContext: SubscriberContext,
fallback = false,
conversationId?: string,
) {
): Promise<StdOutgoingEnvelope> {
const settings = await this.settingService.getSettings();
const blockMessage: BlockMessage =
fallback && block.options?.fallback
@ -592,13 +592,18 @@ export class BlockService extends BaseService<
);
// Process custom plugin block
try {
return await plugin?.process(block, context, conversationId);
const envelope = await plugin?.process(block, context, conversationId);
if (!envelope) {
throw new Error('Unable to find envelope');
}
return envelope;
} 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.');
}
throw new Error('Invalid message format.');
}
}

View File

@ -193,9 +193,9 @@ describe('BlockService', () => {
});
const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] });
const webSubscriber = await subscriberService.findOne({
const webSubscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-web-1',
});
}))!;
event.setSender(webSubscriber);
@ -260,9 +260,9 @@ describe('BlockService', () => {
ipAddress: '1.1.1.1',
agent: 'Chromium',
});
const webSubscriber = await subscriberService.findOne({
const webSubscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-web-1',
});
}))!;
event.setSender(webSubscriber);
const clearMock = jest
@ -314,9 +314,9 @@ describe('BlockService', () => {
ipAddress: '1.1.1.1',
agent: 'Chromium',
});
const webSubscriber = await subscriberService.findOne({
const webSubscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-web-2',
});
}))!;
event.setSender(webSubscriber);
const captured = await botService.processConversationMessage(event);

View File

@ -21,10 +21,8 @@ import {
getDefaultConversationContext,
} from '../schemas/conversation.schema';
import { Context } from '../schemas/types/context';
import {
IncomingMessageType,
StdOutgoingEnvelope,
} from '../schemas/types/message';
import { IncomingMessageType } from '../schemas/types/message';
import { SubscriberContext } from '../schemas/types/subscriberContext';
import { BlockService } from './block.service';
import { ConversationService } from './conversation.service';
@ -70,14 +68,13 @@ export class BotService {
);
// Process message : Replace tokens with context data and then send the message
const recipient = event.getSender();
const envelope: StdOutgoingEnvelope =
await this.blockService.processMessage(
block,
context,
recipient?.context,
fallback,
conservationId,
);
const envelope = await this.blockService.processMessage(
block,
context,
recipient?.context as SubscriberContext,
fallback,
conservationId,
);
// Send message through the right channel
const response = await event
@ -251,8 +248,8 @@ export class BotService {
// If there's labels, they should be already have been assigned
assign_labels: [],
trigger_labels: [],
attachedBlock: undefined,
category: undefined,
attachedBlock: null,
category: null,
previousBlocks: [],
};
convo.context.attempt++;

View File

@ -117,6 +117,12 @@ export class ChatService {
try {
const msg = await this.messageService.create(received);
const populatedMsg = await this.messageService.findOneAndPopulate(msg.id);
if (!populatedMsg) {
this.logger.warn('Unable to find populated message.', event);
throw new Error(`Unable to find Message by ID ${msg.id} not found`);
}
this.websocketGateway.broadcastMessageReceived(populatedMsg, subscriber);
this.eventEmitter.emit('hook:stats:entry', 'incoming', 'Incoming');
this.eventEmitter.emit(
@ -290,7 +296,9 @@ export class ChatService {
@OnEvent('hook:subscriber:postCreate')
async onSubscriberCreate({ _id }: SubscriberDocument) {
const subscriber = await this.subscriberService.findOne(_id);
this.websocketGateway.broadcastSubscriberNew(subscriber);
if (subscriber) {
this.websocketGateway.broadcastSubscriberNew(subscriber);
}
}
/**
@ -301,6 +309,8 @@ export class ChatService {
@OnEvent('hook:subscriber:postUpdate')
async onSubscriberUpdate({ _id }: SubscriberDocument) {
const subscriber = await this.subscriberService.findOne(_id);
this.websocketGateway.broadcastSubscriberUpdate(subscriber);
if (subscriber) {
this.websocketGateway.broadcastSubscriberUpdate(subscriber);
}
}
}

View File

@ -70,6 +70,7 @@ export class ConversationService extends BaseService<
) {
const msgType = event.getMessageType();
const profile = event.getSender();
// Capture channel specific context data
convo.context.channel = event.getHandler().getName();
convo.context.text = event.getText();
@ -83,7 +84,7 @@ export class ConversationService extends BaseService<
// Capture user entry in context vars
if (captureVars && next.capture_vars && next.capture_vars.length > 0) {
next.capture_vars.forEach((capture) => {
let contextValue: string | Payload;
let contextValue: string | Payload | undefined;
const nlp = event.getNLP();
@ -105,7 +106,7 @@ export class ConversationService extends BaseService<
if (capture.entity === -1) {
// Capture the whole message
contextValue =
['message', 'quick_reply'].indexOf(msgType) !== -1
msgType && ['message', 'quick_reply'].indexOf(msgType) !== -1
? event.getText()
: event.getPayload();
} else if (capture.entity === -2) {
@ -115,13 +116,16 @@ export class ConversationService extends BaseService<
contextValue =
typeof contextValue === 'string' ? contextValue.trim() : contextValue;
if (contextVars[capture.context_var]?.permanent) {
if (
profile.context?.vars &&
contextVars[capture.context_var]?.permanent
) {
Logger.debug(
`Adding context var to subscriber: ${capture.context_var} = ${contextValue}`,
);
profile.context.vars[capture.context_var] = contextValue;
} else {
convo.context.vars[capture.context_var] = contextValue;
convo.context!.vars[capture.context_var] = contextValue;
}
});
}
@ -160,6 +164,7 @@ export class ConversationService extends BaseService<
// Deal with load more in the case of a list display
if (
next.options &&
next.options.content &&
(next.options.content.display === OutgoingMessageFormat.list ||
next.options.content.display === OutgoingMessageFormat.carousel)

View File

@ -90,16 +90,15 @@ describe('MessageService', () => {
allSubscribers = await subscriberRepository.findAll();
allUsers = await userRepository.findAll();
allMessages = await messageRepository.findAll();
message = await messageRepository.findOne({ mid: 'mid-1' });
sender = await subscriberRepository.findOne(message['sender']);
recipient = await subscriberRepository.findOne(message['recipient']);
user = await userRepository.findOne(message['sentBy']);
message = (await messageRepository.findOne({ mid: 'mid-1' }))!;
sender = (await subscriberRepository.findOne(message.sender!))!;
recipient = (await subscriberRepository.findOne(message.recipient!))!;
user = (await userRepository.findOne(message.sentBy!))!;
messagesWithSenderAndRecipient = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']).id,
recipient: allSubscribers.find(({ id }) => id === message['recipient'])
.id,
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
sender: allSubscribers.find(({ id }) => id === message.sender)?.id,
recipient: allSubscribers.find(({ id }) => id === message.recipient)?.id,
sentBy: allUsers.find(({ id }) => id === message.sentBy)?.id,
}));
});
@ -131,9 +130,9 @@ describe('MessageService', () => {
const result = await messageService.findPageAndPopulate({}, pageQuery);
const messagesWithSenderAndRecipient = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']),
recipient: allSubscribers.find(({ id }) => id === message['recipient']),
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
sender: allSubscribers.find(({ id }) => id === message.sender),
recipient: allSubscribers.find(({ id }) => id === message.recipient),
sentBy: allUsers.find(({ id }) => id === message.sentBy)?.id,
}));
expect(messageRepository.findPageAndPopulate).toHaveBeenCalledWith(
@ -150,7 +149,7 @@ describe('MessageService', () => {
new Date().setMonth(new Date().getMonth() + 1),
);
const result = await messageService.findHistoryUntilDate(
sender,
sender!,
until,
30,
);
@ -166,16 +165,16 @@ describe('MessageService', () => {
it('should return history since given date', async () => {
const since: Date = new Date();
const result = await messageService.findHistorySinceDate(
sender,
sender!,
since,
30,
);
const messagesWithSenderAndRecipient = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']).id,
recipient: allSubscribers.find(({ id }) => id === message['recipient'])
.id,
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
sender: allSubscribers.find(({ id }) => id === message.sender)?.id,
recipient: allSubscribers.find(({ id }) => id === message.recipient)
?.id,
sentBy: allUsers.find(({ id }) => id === message.sentBy)?.id,
}));
const historyMessages = messagesWithSenderAndRecipient.filter(
(message) => message.createdAt > since,

View File

@ -46,8 +46,8 @@ export class MessageService extends BaseService<
@Optional() gateway?: WebsocketGateway,
) {
super(messageRepository);
this.logger = logger;
this.gateway = gateway;
if (logger) this.logger = logger;
if (gateway) this.gateway = gateway;
}
/**

View File

@ -90,9 +90,9 @@ describe('SubscriberService', () => {
describe('findOneAndPopulate', () => {
it('should find subscribers, and foreach subscriber populate its corresponding labels', async () => {
jest.spyOn(subscriberService, 'findOneAndPopulate');
const subscriber = await subscriberRepository.findOne({
const subscriber = (await subscriberRepository.findOne({
first_name: 'Jhon',
});
}))!;
const result = await subscriberService.findOneAndPopulate(subscriber.id);
expect(subscriberService.findOneAndPopulate).toHaveBeenCalledWith(
@ -133,7 +133,7 @@ describe('SubscriberService', () => {
await subscriberService.findOneByForeignId('foreign-id-dimelo');
const subscriber = allSubscribers.find(
({ foreign_id }) => foreign_id === 'foreign-id-dimelo',
);
)!;
expect(subscriberRepository.findOneByForeignId).toHaveBeenCalled();
expect(result).toEqualPayload({

View File

@ -52,7 +52,7 @@ export class SubscriberService extends BaseService<
@Optional() gateway?: WebsocketGateway,
) {
super(repository);
this.gateway = gateway;
if (gateway) this.gateway = gateway;
}
/**

View File

@ -6,7 +6,7 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { Request, Response } from 'express';
import multer, { diskStorage, memoryStorage } from 'multer';
@ -236,7 +236,7 @@ export default abstract class BaseWebChannelHandler<
...message,
author: 'chatbot',
read: true, // Temporary fix as read is false in the bd
mid: anyMessage.mid,
mid: anyMessage.mid || this.generateId(),
handover: !!anyMessage.handover,
createdAt: anyMessage.createdAt,
});
@ -524,6 +524,9 @@ export default abstract class BaseWebChannelHandler<
const fetchMessages = async (req: Request, res: Response, retrials = 1) => {
try {
if (!req.query.since)
throw new BadRequestException(`QueryParam 'since' is missing`);
const since = new Date(req.query.since.toString());
const messages = await this.pollMessages(req, since);
if (messages.length === 0 && retrials <= 5) {
@ -618,7 +621,12 @@ export default abstract class BaseWebChannelHandler<
size: Buffer.byteLength(data.file),
type: data.type,
});
return attachment;
if (attachment) {
return attachment;
} else {
throw new Error('Unable to retrieve stored attachment');
}
} catch (err) {
this.logger.error(
'Web Channel Handler : Unable to store uploaded file',
@ -636,7 +644,7 @@ export default abstract class BaseWebChannelHandler<
async handleWebUpload(
req: Request,
res: Response,
): Promise<Attachment | null> {
): Promise<Attachment | null | undefined> {
try {
const upload = multer({
limits: {
@ -662,7 +670,9 @@ export default abstract class BaseWebChannelHandler<
reject(new Error('Unable to upload file!'));
}
resolve(req.file);
if (req.file) {
resolve(req.file);
}
});
},
);
@ -675,12 +685,18 @@ export default abstract class BaseWebChannelHandler<
return null;
}
const attachment = await this.attachmentService.store(file, {
name: file.originalname,
size: file.size,
type: file.mimetype,
});
return attachment;
if (file) {
const attachment = await this.attachmentService.store(file, {
name: file.originalname,
size: file.size,
type: file.mimetype,
});
if (attachment) {
return attachment;
}
throw new Error('Unable to store uploaded file');
}
} catch (err) {
this.logger.error(
'Web Channel Handler : Unable to store uploaded file',
@ -700,7 +716,7 @@ export default abstract class BaseWebChannelHandler<
async handleUpload(
req: Request | SocketRequest,
res: Response | SocketResponse,
): Promise<Attachment | null> {
): Promise<Attachment | null | undefined> {
// Check if any file is provided
if (!req.session.web) {
this.logger.debug('Web Channel Handler : No session provided');
@ -747,7 +763,7 @@ export default abstract class BaseWebChannelHandler<
return {
isSocket: this.isSocketRequest(req),
ipAddress: this.getIpAddress(req),
agent: req.headers['user-agent'],
agent: req.headers['user-agent'] || 'browser',
};
}
@ -998,7 +1014,13 @@ export default abstract class BaseWebChannelHandler<
},
};
if (message.quickReplies && message.quickReplies.length > 0) {
payload.data.quick_replies = message.quickReplies;
return {
...payload,
data: {
...payload.data,
quick_replies: message.quickReplies,
} as Web.OutgoingFileMessageData,
};
}
return payload;
}

View File

@ -27,26 +27,31 @@ type WebEventAdapter =
eventType: StdEventType.unknown;
messageType: never;
raw: Web.Event;
attachment: never;
}
| {
eventType: StdEventType.read;
messageType: never;
raw: Web.StatusReadEvent;
attachment: never;
}
| {
eventType: StdEventType.delivery;
messageType: never;
raw: Web.StatusDeliveryEvent;
attachment: never;
}
| {
eventType: StdEventType.typing;
messageType: never;
raw: Web.StatusTypingEvent;
attachment: never;
}
| {
eventType: StdEventType.message;
messageType: IncomingMessageType.message;
raw: Web.IncomingMessage<Web.IncomingTextMessage>;
attachment: never;
}
| {
eventType: StdEventType.message;
@ -54,11 +59,13 @@ type WebEventAdapter =
| IncomingMessageType.postback
| IncomingMessageType.quick_reply;
raw: Web.IncomingMessage<Web.IncomingPayloadMessage>;
attachment: never;
}
| {
eventType: StdEventType.message;
messageType: IncomingMessageType.location;
raw: Web.IncomingMessage<Web.IncomingLocationMessage>;
attachment: never;
}
| {
eventType: StdEventType.message;
@ -68,11 +75,9 @@ type WebEventAdapter =
};
// eslint-disable-next-line prettier/prettier
export default class WebEventWrapper<N extends ChannelName> extends EventWrapper<
WebEventAdapter,
Web.Event,
N
> {
export default class WebEventWrapper<
N extends ChannelName,
> extends EventWrapper<WebEventAdapter, Web.Event, N> {
/**
* Constructor : channel's event wrapper
*

View File

@ -6,7 +6,7 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { HelperService } from '@/helper/helper.service';
@ -77,9 +77,14 @@ export class NlpService {
async handleEntityDelete(entity: NlpEntity) {
// Synchonize new entity with NLP provider
try {
const helper = await this.helperService.getDefaultNluHelper();
await helper.deleteEntity(entity.foreign_id);
this.logger.debug('Deleted entity successfully synced!', entity);
if (entity.foreign_id) {
const helper = await this.helperService.getDefaultNluHelper();
await helper.deleteEntity(entity.foreign_id);
this.logger.debug('Deleted entity successfully synced!', entity);
} else {
this.logger.error(`Entity ${entity} is missing foreign_id`);
throw new NotFoundException(`Entity ${entity} is missing foreign_id`);
}
} catch (err) {
this.logger.error('Unable to sync deleted entity', err);
}
@ -138,8 +143,10 @@ export class NlpService {
const populatedValue = await this.nlpValueService.findOneAndPopulate(
value.id,
);
await helper.deleteValue(populatedValue);
this.logger.debug('Deleted value successfully synced!', value);
if (populatedValue) {
await helper.deleteValue(populatedValue);
this.logger.debug('Deleted value successfully synced!', value);
}
} catch (err) {
this.logger.error('Unable to sync deleted value', err);
}

View File

@ -29,8 +29,6 @@ export const blockDefaultValues: TBlockFixtures['defaultValues'] = {
trigger_channels: [],
builtin: false,
starts_conversation: false,
attachedBlock: null,
attachedToBlock: null,
};
export const blocks: TBlockFixtures['values'][] = [

View File

@ -58,6 +58,9 @@ const conversations: ConversationCreateDto[] = [
labels: [],
assignedTo: null,
channel: { name: 'messenger-channel' },
avatar: null,
context: {},
assignedAt: new Date(),
},
skip: {},
attempt: 0,
@ -104,6 +107,9 @@ const conversations: ConversationCreateDto[] = [
labels: [],
assignedTo: null,
channel: { name: 'web-channel' },
avatar: null,
context: {},
assignedAt: new Date(),
},
skip: {},
attempt: 0,
@ -136,8 +142,10 @@ export const installConversationTypeFixtures = async () => {
conversationFixtures.map((conversationFixture) => ({
...conversationFixture,
sender: subscribers[parseInt(conversationFixture.sender)].id,
current: blocks[parseInt(conversationFixture.current)].id,
next: conversationFixture.next.map((n) => blocks[parseInt(n)].id),
current: conversationFixture?.current
? blocks[parseInt(conversationFixture.current)]?.id
: undefined,
next: conversationFixture.next?.map((n) => blocks[parseInt(n)].id),
})),
);
};

View File

@ -99,23 +99,23 @@ export const baseBlockInstance = {
...modelInstance,
};
export const blockEmpty: BlockFull = {
export const blockEmpty = {
...baseBlockInstance,
name: 'Empty',
patterns: [],
message: [''],
nextBlocks: [],
};
} as unknown as BlockFull;
// Translation Data
export const textResult = ['Hi back !'];
export const textBlock: BlockFull = {
export const textBlock = {
name: 'message',
patterns: ['Hi'],
message: textResult,
...baseBlockInstance,
};
} as unknown as BlockFull;
export const quickRepliesResult = [
"What's your favorite color?",
@ -124,7 +124,7 @@ export const quickRepliesResult = [
'Red',
];
export const quickRepliesBlock: BlockFull = {
export const quickRepliesBlock = {
name: 'message',
patterns: ['colors'],
message: {
@ -148,7 +148,7 @@ export const quickRepliesBlock: BlockFull = {
],
},
...baseBlockInstance,
};
} as unknown as BlockFull;
export const buttonsResult = [
'What would you like to know about us?',
@ -157,7 +157,7 @@ export const buttonsResult = [
'Approach',
];
export const buttonsBlock: BlockFull = {
export const buttonsBlock = {
name: 'message',
patterns: ['about'],
message: {
@ -181,9 +181,9 @@ export const buttonsBlock: BlockFull = {
],
},
...baseBlockInstance,
};
} as unknown as BlockFull;
export const attachmentBlock: BlockFull = {
export const attachmentBlock = {
name: 'message',
patterns: ['image'],
message: {
@ -197,7 +197,7 @@ export const attachmentBlock: BlockFull = {
quickReplies: [],
},
...baseBlockInstance,
};
} as unknown as BlockFull;
export const allBlocksStringsResult = [
'Hi back !',
@ -216,7 +216,7 @@ export const allBlocksStringsResult = [
/////////
export const blockGetStarted: BlockFull = {
export const blockGetStarted = {
...baseBlockInstance,
name: 'Get Started',
patterns: [
@ -247,7 +247,7 @@ export const blockGetStarted: BlockFull = {
],
trigger_labels: customerLabelsMock,
message: ['Welcome! How are you ? '],
};
} as unknown as BlockFull;
const patternsProduct: Pattern[] = [
'produit',
@ -264,7 +264,7 @@ const patternsProduct: Pattern[] = [
],
];
export const blockProductListMock: BlockFull = {
export const blockProductListMock = {
...baseBlockInstance,
name: 'test_list',
patterns: patternsProduct,
@ -280,11 +280,11 @@ export const blockProductListMock: BlockFull = {
limit: 0,
},
},
};
} as unknown as BlockFull;
export const blockCarouselMock: BlockFull = {
export const blockCarouselMock = {
...blockProductListMock,
options: blockCarouselOptions,
};
} as unknown as BlockFull;
export const blocks: BlockFull[] = [blockGetStarted, blockEmpty];

View File

@ -28,6 +28,8 @@ export const subscriberInstance: Subscriber = {
name: 'web-channel',
},
labels: [],
avatar: null,
context: {},
...modelInstance,
};

View File

@ -13,14 +13,20 @@ type TSortProps<T> = {
order?: 'desc' | 'asc';
};
const sort = <R, S, T extends { createdAt?: string } = R & S>({
type TCreatedAt = { createdAt?: string | Date };
const sort = <R extends TCreatedAt, S, T extends TCreatedAt = R & S>({
row1,
row2,
field = 'createdAt',
order = 'desc',
}: TSortProps<T>) => (order === 'asc' && row1[field] > row2[field] ? 1 : -1);
export const sortRowsBy = <R, S, T = R & S>(
export const sortRowsBy = <
R extends TCreatedAt,
S,
T extends TCreatedAt = R & S,
>(
row1: T,
row2: T,
field?: keyof T,

View File

@ -65,12 +65,16 @@ type TAllowedKeys<T, TStub, TValue = (string | null | undefined)[]> = {
>]: TValue;
};
type TVirtualFields<T> = Pick<T, TFilterKeysOfType<T, undefined>>;
export type TValidateProps<T, TStub> = {
dto:
| Partial<TAllowedKeys<T, TStub>>
| Partial<TAllowedKeys<T, TStub, string>>;
allowedIds: TAllowedKeys<T, TStub> &
TAllowedKeys<T, TStub, string | null | undefined>;
allowedIds: Omit<
TAllowedKeys<T, TStub, null | undefined | string | string[]>,
keyof TVirtualFields<T>
>;
};
//populate types