diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 0e4643f0..366ae7f3 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -51,7 +51,12 @@ 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 { mockWebChannelData, textBlock } from '@/utils/test/mocks/block'; +import { + buttonsBlock, + mockWebChannelData, + quickRepliesBlock, + textBlock, +} from '@/utils/test/mocks/block'; import { conversationGetStarted } from '@/utils/test/mocks/conversation'; import { closeInMongodConnection, @@ -570,4 +575,168 @@ describe('BotService', () => { expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', mockConvo); }); }); + + describe('shouldAttemptLocalFallback', () => { + const mockEvent = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, + ); + + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should return true when fallback is active and max attempts not reached', () => { + const result = botService.shouldAttemptLocalFallback( + { + ...conversationGetStarted, + context: { ...conversationGetStarted.context, attempt: 1 }, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: true, + max_attempts: 3, + message: ['Please pick an option.'], + }, + }, + }, + }, + mockEvent, + ); + expect(result).toBe(true); + }); + + it('should return false when fallback is not active', () => { + const result = botService.shouldAttemptLocalFallback( + { + ...conversationGetStarted, + context: { ...conversationGetStarted.context, attempt: 1 }, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: false, + max_attempts: 0, + message: [], + }, + }, + }, + }, + mockEvent, + ); + expect(result).toBe(false); + }); + + it('should return false when max attempts reached', () => { + const result = botService.shouldAttemptLocalFallback( + { + ...conversationGetStarted, + context: { ...conversationGetStarted.context, attempt: 3 }, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: true, + max_attempts: 3, + message: ['Please pick an option.'], + }, + }, + }, + }, + mockEvent, + ); + expect(result).toBe(false); + }); + + it('should return false when fallback options are missing', () => { + const result = botService.shouldAttemptLocalFallback( + { + ...conversationGetStarted, + current: { + ...conversationGetStarted.current, + options: {}, + }, + }, + mockEvent, + ); + + expect(result).toBe(false); + }); + }); + + describe('findNextMatchingBlock', () => { + const mockEvent = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, + ); + + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should return a matching block if one is found and fallback is not active', async () => { + jest.spyOn(blockService, 'match').mockResolvedValue(buttonsBlock); + + const result = await botService.findNextMatchingBlock( + { + ...conversationGetStarted, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: false, + message: [], + max_attempts: 0, + }, + }, + }, + next: [quickRepliesBlock, buttonsBlock].map((b) => ({ + ...b, + trigger_labels: b.trigger_labels.map(({ id }) => id), + assign_labels: b.assign_labels.map(({ id }) => id), + nextBlocks: [], + attachedBlock: null, + category: null, + previousBlocks: undefined, + attachedToBlock: undefined, + })), + }, + mockEvent, + ); + expect(result).toBe(buttonsBlock); + }); + + it('should return undefined if no matching block is found', async () => { + jest.spyOn(blockService, 'match').mockResolvedValue(undefined); + + const result = await botService.findNextMatchingBlock( + { + ...conversationGetStarted, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: true, + message: ['Please pick an option.'], + max_attempts: 1, + }, + }, + }, + }, + mockEvent, + ); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 0eeaf5ea..835547e2 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -24,7 +24,7 @@ import { OutgoingMessageFormat, StdOutgoingMessageEnvelope, } from '../schemas/types/message'; -import { BlockOptions } from '../schemas/types/options'; +import { BlockOptions, FallbackOptions } from '../schemas/types/options'; import { BlockService } from './block.service'; import { ConversationService } from './conversation.service'; @@ -284,20 +284,47 @@ export class BotService { } } + /** + * Finds the next block that matches the event criteria within the conversation's next blocks. + * + * @param convo - The current conversation object containing context and state. + * @param event - The incoming event that triggered the conversation flow. + * + * @returns A promise that resolves with the matched block or undefined if no match is found. + */ + async findNextMatchingBlock( + convo: ConversationFull, + event: EventWrapper, + ): Promise { + const fallbackOptions: FallbackOptions = + this.blockService.getFallbackOptions(convo.current); + // We will avoid having multiple matches when we are not at the start of a conversation + // and only if local fallback is enabled + const canHaveMultipleMatches = !fallbackOptions?.active; + // Find the next block that matches + const nextBlocks = await this.blockService.findAndPopulate({ + _id: { $in: convo.next.map(({ id }) => id) }, + }); + return await this.blockService.match( + nextBlocks, + event, + canHaveMultipleMatches, + ); + } + /** * Determines if a fallback should be attempted based on the event type, fallback options, and conversation context. * - * @param event - The incoming event that triggered the conversation flow. - * @param fallbackOptions - The options for fallback behavior defined in the block. * @param convo - The current conversation object containing context and state. + * @param event - The incoming event that triggered the conversation flow. * * @returns A boolean indicating whether a fallback should be attempted. */ - private shouldAttemptLocalFallback( - event: EventWrapper, - fallbackOptions: BlockOptions['fallback'], + shouldAttemptLocalFallback( convo: ConversationFull, + event: EventWrapper, ): boolean { + const fallbackOptions = this.blockService.getFallbackOptions(convo.current); return ( event.getMessageType() === IncomingMessageType.message && !!fallbackOptions?.active && @@ -346,10 +373,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 = undefined; - if ( - !matchedBlock && - this.shouldAttemptLocalFallback(event, fallbackOptions, convo) - ) { + if (!matchedBlock && this.shouldAttemptLocalFallback(convo, event)) { // Trigger block fallback // NOTE : current is not populated, this may cause some anomaly fallbackBlock = {