diff --git a/api/src/chat/constants/block.ts b/api/src/chat/constants/block.ts new file mode 100644 index 00000000..d8c36afe --- /dev/null +++ b/api/src/chat/constants/block.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2025 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). + */ + +import { FallbackOptions } from '../schemas/types/options'; + +export function getDefaultFallbackOptions(): FallbackOptions { + return { + active: false, + max_attempts: 0, + message: [], + }; +} diff --git a/api/src/chat/constants/conversation.ts b/api/src/chat/constants/conversation.ts new file mode 100644 index 00000000..2191e90d --- /dev/null +++ b/api/src/chat/constants/conversation.ts @@ -0,0 +1,28 @@ +/* + * Copyright © 2025 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). + */ + +import { Subscriber } from '../schemas/subscriber.schema'; +import { Context } from '../schemas/types/context'; + +export function getDefaultConversationContext(): Context { + return { + vars: {}, // Used for capturing vars from user entries + user: { + first_name: '', + last_name: '', + // @TODO: Typing is not correct + } 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 + }; +} diff --git a/api/src/chat/schemas/conversation.schema.ts b/api/src/chat/schemas/conversation.schema.ts index 6d5a0aae..312745bf 100644 --- a/api/src/chat/schemas/conversation.schema.ts +++ b/api/src/chat/schemas/conversation.schema.ts @@ -17,27 +17,12 @@ import { THydratedDocument, } from '@/utils/types/filter.types'; +import { getDefaultConversationContext } from '../constants/conversation'; + 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, minimize: false }) class ConversationStub extends BaseSchema { @Prop({ diff --git a/api/src/chat/schemas/types/options.ts b/api/src/chat/schemas/types/options.ts index f413a56d..b483a8f6 100644 --- a/api/src/chat/schemas/types/options.ts +++ b/api/src/chat/schemas/types/options.ts @@ -29,16 +29,18 @@ export const contentOptionsSchema = z.object({ export type ContentOptions = z.infer; +export const fallbackOptionsSchema = z.object({ + active: z.boolean(), + message: z.array(z.string()), + max_attempts: z.number().finite(), +}); + +export type FallbackOptions = z.infer; + export const BlockOptionsSchema = z.object({ typing: z.number().optional(), content: contentOptionsSchema.optional(), - fallback: z - .object({ - active: z.boolean(), - message: z.array(z.string()), - max_attempts: z.number().finite(), - }) - .optional(), + fallback: fallbackOptionsSchema.optional(), assignTo: z.string().optional(), effects: z.array(z.string()).optional(), }); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index af561a7e..82918649 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -23,6 +23,7 @@ import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp'; import { BaseService } from '@/utils/generics/base-service'; import { getRandomElement } from '@/utils/helpers/safeRandom'; +import { getDefaultFallbackOptions } from '../constants/block'; import { BlockDto } from '../dto/block.dto'; import { EnvelopeFactory } from '../helpers/envelope-factory'; import { BlockRepository } from '../repositories/block.repository'; @@ -40,6 +41,7 @@ import { StdOutgoingEnvelope, StdOutgoingSystemEnvelope, } from '../schemas/types/message'; +import { FallbackOptions } from '../schemas/types/options'; import { NlpPattern, PayloadPattern } from '../schemas/types/pattern'; import { Payload } from '../schemas/types/quick-reply'; import { SubscriberContext } from '../schemas/types/subscriberContext'; @@ -775,6 +777,16 @@ export class BlockService extends BaseService< throw new Error('Invalid message format.'); } + /** + * Retrieves the fallback options for a block. + * + * @param block - The block to retrieve fallback options from. + * @returns The fallback options for the block, or default options if not specified. + */ + getFallbackOptions(block: T): FallbackOptions { + return block.options?.fallback ?? getDefaultFallbackOptions(); + } + /** * Updates the `trigger_labels` and `assign_labels` fields of a block when a label is deleted. * 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 7052d961..835547e2 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -14,20 +14,17 @@ import EventWrapper from '@/channel/lib/EventWrapper'; import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; +import { getDefaultConversationContext } from '../constants/conversation'; import { MessageCreateDto } from '../dto/message.dto'; import { BlockFull } from '../schemas/block.schema'; -import { - Conversation, - ConversationFull, - getDefaultConversationContext, -} from '../schemas/conversation.schema'; +import { Conversation, ConversationFull } from '../schemas/conversation.schema'; import { Context } from '../schemas/types/context'; import { IncomingMessageType, 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'; @@ -287,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 && @@ -349,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 = {