refactor: handle ongoing conversation message

This commit is contained in:
Mohamed Marrouchi 2025-06-11 14:29:34 +01:00
parent 629a07fef8
commit d88959cea2
2 changed files with 162 additions and 37 deletions

View File

@ -88,6 +88,7 @@ import { SubscriberService } from './subscriber.service';
describe('BotService', () => {
let blockService: BlockService;
let subscriberService: SubscriberService;
let conversationService: ConversationService;
let botService: BotService;
let handler: WebChannelHandler;
let eventEmitter: EventEmitter2;
@ -192,14 +193,21 @@ describe('BotService', () => {
},
],
});
[subscriberService, botService, blockService, eventEmitter, handler] =
await getMocks([
SubscriberService,
BotService,
BlockService,
EventEmitter2,
WebChannelHandler,
]);
[
subscriberService,
conversationService,
botService,
blockService,
eventEmitter,
handler,
] = await getMocks([
SubscriberService,
ConversationService,
BotService,
BlockService,
EventEmitter2,
WebChannelHandler,
]);
});
afterEach(jest.clearAllMocks);
@ -351,4 +359,102 @@ describe('BotService', () => {
expect(captured).toBe(false);
expect(triggeredEvents).toEqual([]);
});
describe('proceedToNextBlock', () => {
it('should emit stats and call triggerBlock, returning true on success and reset attempt if not fallback', async () => {
const convo = {
id: 'convo1',
context: { attempt: 2 },
next: [],
sender: 'user1',
active: true,
} as unknown as ConversationFull;
const next = { id: 'block1', name: 'Block 1' } as BlockFull;
const event = {} as any;
const fallback = false;
jest
.spyOn(conversationService, 'storeContextData')
.mockImplementation((convo, _next, _event, _captureVars) => {
return Promise.resolve({
...convo,
} as Conversation);
});
jest.spyOn(botService, 'triggerBlock').mockResolvedValue(undefined);
const emitSpy = jest.spyOn(eventEmitter, 'emit');
const result = await botService.proceedToNextBlock(
convo,
next,
event,
fallback,
);
expect(emitSpy).toHaveBeenCalledWith(
'hook:stats:entry',
'popular',
next.name,
);
expect(botService.triggerBlock).toHaveBeenCalledWith(
event,
expect.objectContaining({ id: 'convo1' }),
next,
fallback,
);
expect(result).toBe(true);
expect(convo.context.attempt).toBe(0);
});
it('should increment attempt if fallback is true', async () => {
const convo = {
id: 'convo2',
context: { attempt: 1 },
next: [],
sender: 'user2',
active: true,
} as any;
const next = { id: 'block2', name: 'Block 2' } as any;
const event = {} as any;
const fallback = true;
const result = await botService.proceedToNextBlock(
convo,
next,
event,
fallback,
);
expect(convo.context.attempt).toBe(2);
expect(result).toBe(true);
});
it('should handle errors and emit conversation:end, returning false', async () => {
const convo = {
id: 'convo3',
context: { attempt: 1 },
next: [],
sender: 'user3',
active: true,
} as any;
const next = { id: 'block3', name: 'Block 3' } as any;
const event = {} as any;
const fallback = false;
jest
.spyOn(conversationService, 'storeContextData')
.mockRejectedValue(new Error('fail'));
const emitSpy = jest.spyOn(eventEmitter, 'emit');
const result = await botService.proceedToNextBlock(
convo,
next,
event,
fallback,
);
expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', convo);
expect(result).toBe(false);
});
});
});

View File

@ -11,6 +11,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { BotStatsType } from '@/analytics/schemas/bot-stats.schema';
import EventWrapper from '@/channel/lib/EventWrapper';
import { HelperService } from '@/helper/helper.service';
import { LoggerService } from '@/logger/logger.service';
import { SettingService } from '@/setting/services/setting.service';
@ -41,6 +42,7 @@ export class BotService {
private readonly conversationService: ConversationService,
private readonly subscriberService: SubscriberService,
private readonly settingService: SettingService,
private readonly helperService: HelperService,
) {}
/**
@ -243,6 +245,49 @@ export class BotService {
}
}
/**
* Handles advancing the conversation to the specified *next* block.
*
* 1. Updates popular blocks stats.
* 2. Persists the updated conversation context.
* 3. Triggers the next block.
* 4. Ends the conversation if an unrecoverable error occurs.
*/
async proceedToNextBlock(
convo: ConversationFull,
next: BlockFull,
event: EventWrapper<any, any>,
fallback: boolean,
): Promise<boolean> {
// Increment stats about popular blocks
this.eventEmitter.emit('hook:stats:entry', BotStatsType.popular, next.name);
this.logger.debug(
'Proceeding to next block ',
next.id,
' for conversation ',
convo.id,
);
try {
convo.context.attempt = fallback ? convo.context.attempt + 1 : 0;
const updatedConversation =
await this.conversationService.storeContextData(
convo,
next,
event,
// If this is a local fallback then we dont capture vars.
!fallback,
);
await this.triggerBlock(event, updatedConversation, next, fallback);
return true;
} catch (err) {
this.logger.error('Unable to proceed to the next block!', err);
this.eventEmitter.emit('hook:conversation:end', convo);
return false;
}
}
/**
* 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,
@ -283,7 +328,7 @@ export class BotService {
);
// 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;
let fallbackBlock: BlockFull | undefined = undefined;
if (
!matchedBlock &&
event.getMessageType() === IncomingMessageType.message &&
@ -303,11 +348,7 @@ export class BotService {
category: null,
previousBlocks: [],
};
convo.context.attempt++;
fallback = true;
} else {
convo.context.attempt = 0;
fallbackBlock = undefined;
}
const next = matchedBlock || fallbackBlock;
@ -315,30 +356,8 @@ export class BotService {
this.logger.debug('Responding ...', convo.id);
if (next) {
// Increment stats about popular blocks
this.eventEmitter.emit(
'hook:stats:entry',
BotStatsType.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.triggerBlock(event, updatedConversation, next, fallback);
} catch (err) {
this.logger.error('Unable to store context data!', err);
return this.eventEmitter.emit('hook:conversation:end', convo);
}
return true;
// Proceed to the execution of the next block
return await this.proceedToNextBlock(convo, next, event, fallback);
} 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.