From d88959cea2bbf597486997ea72539700a9fed6f9 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 11 Jun 2025 14:29:34 +0100 Subject: [PATCH] refactor: handle ongoing conversation message --- api/src/chat/services/bot.service.spec.ts | 122 ++++++++++++++++++++-- api/src/chat/services/bot.service.ts | 77 +++++++++----- 2 files changed, 162 insertions(+), 37 deletions(-) diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index d8b61596..9fb7fa87 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -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); + }); + }); }); diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index ae3dac76..4007d70e 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -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, + fallback: boolean, + ): Promise { + // 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 don’t 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.