From fa5f835cefa6f740a302f11c6769128df00db126 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Tue, 20 May 2025 15:04:16 +0100 Subject: [PATCH 1/7] test: add unit tests --- api/src/chat/services/block.service.spec.ts | 319 +++++++++++++++++++- 1 file changed, 317 insertions(+), 2 deletions(-) diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index cc94f34e..1c912766 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -16,7 +16,7 @@ import { subscriberWithLabels, subscriberWithoutLabels, } from '@/channel/lib/__test__/subscriber.mock'; -import { PayloadType } from '@/chat/schemas/types/button'; +import { ButtonType, PayloadType } from '@/chat/schemas/types/button'; import { ContentTypeRepository } from '@/cms/repositories/content-type.repository'; import { ContentRepository } from '@/cms/repositories/content.repository'; import { ContentTypeModel } from '@/cms/schemas/content-type.schema'; @@ -84,8 +84,14 @@ import { BlockRepository } from '../repositories/block.repository'; import { Block, BlockFull, BlockModel } from '../schemas/block.schema'; import { Category, CategoryModel } from '../schemas/category.schema'; import { LabelModel } from '../schemas/label.schema'; +import { Subscriber } from '../schemas/subscriber.schema'; import { FileType } from '../schemas/types/attachment'; -import { StdOutgoingListMessage } from '../schemas/types/message'; +import { Context } from '../schemas/types/context'; +import { + OutgoingMessageFormat, + StdOutgoingListMessage, +} from '../schemas/types/message'; +import { QuickReplyType } from '../schemas/types/quick-reply'; import { CategoryRepository } from './../repositories/category.repository'; import { BlockService } from './block.service'; @@ -630,6 +636,315 @@ describe('BlockService', () => { }); describe('processMessage', () => { + // generic inputs we re-use + const ctx: Context = { + vars: { + phone: '+1123456789', + }, + user_location: { + address: undefined, + lat: 0, + lon: 0, + }, + user: { id: 'user-id', first_name: 'Jhon', last_name: 'Doe' } as any, + skip: {}, + attempt: 0, + }; // Context + const subCtx: Subscriber['context'] = { + vars: { + color: 'green', + }, + }; // SubscriberContext + const conversationId = 'conv-id'; + + it('should return a text envelope when the block is a text block', async () => { + const block: Block = { + id: 'b1', + message: [ + 'Hello {{context.user.first_name}}, your phone is {{context.vars.phone}} and your favorite color is {{context.vars.color}}', + ], + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: {}, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + false, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.text, + message: { + text: 'Hello Jhon, your phone is +1123456789 and your favorite color is green', + }, + }); + }); + + it('should return a text envelope when the block is a text block (local fallback)', async () => { + const block: Block = { + id: 'b1', + message: ['Hello world!'], + options: { + fallback: { + active: true, + max_attempts: 1, + message: ['Local fallback message ...'], + }, + }, + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + true, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.text, + message: { + text: 'Local fallback message ...', + }, + }); + }); + + it('should return a quick replies envelope when the block message has quickReplies', async () => { + const block: Block = { + id: 'b2', + message: { + text: '{{context.user.first_name}}, is this your phone number? {{context.vars.phone}}', + quickReplies: [ + { content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' }, + { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, + ], + }, + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: {}, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + false, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.quickReplies, + message: { + text: 'Jhon, is this your phone number? +1123456789', + quickReplies: [ + { content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' }, + { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, + ], + }, + }); + }); + + it('should return a quick replies envelope when the block message has quickReplies (local fallback)', async () => { + const block: Block = { + id: 'b2', + message: { + text: '{{context.user.first_name}}, are you there?', + quickReplies: [ + { content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' }, + { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, + ], + }, + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: { + fallback: { + active: true, + max_attempts: 1, + message: ['Local fallback message ...'], + }, + }, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + true, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.text, + message: { + text: 'Local fallback message ...', + }, + }); + }); + + it('should return a buttons envelope when the block message has buttons', async () => { + const block: Block = { + id: 'b3', + message: { + text: '{{context.user.first_name}} {{context.user.last_name}}, what color do you like? {{context.vars.color}}?', + buttons: [ + { type: ButtonType.postback, title: 'Red', payload: 'RED' }, + { type: ButtonType.postback, title: 'Green', payload: 'GREEN' }, + ], + }, + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: {}, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + false, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.buttons, + message: { + text: 'Jhon Doe, what color do you like? green?', + buttons: [ + { + type: ButtonType.postback, + title: 'Red', + payload: 'RED', + }, + { + type: ButtonType.postback, + title: 'Green', + payload: 'GREEN', + }, + ], + }, + }); + }); + + it('should return a buttons envelope when the block message has buttons (local fallback)', async () => { + const block: Block = { + id: 'b3', + message: { + text: '{{context.user.first_name}} {{context.user.last_name}}, what color do you like? {{context.vars.color}}?', + buttons: [ + { type: ButtonType.postback, title: 'Red', payload: 'RED' }, + { type: ButtonType.postback, title: 'Green', payload: 'GREEN' }, + ], + }, + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: { + fallback: { + active: true, + max_attempts: 1, + message: ['Local fallback message ...'], + }, + }, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + true, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.text, + message: { + text: 'Local fallback message ...', + }, + }); + }); + it('should process list message (with limit = 2 and skip = 0)', async () => { const contentType = (await contentTypeService.findOne({ name: 'Product', From 9207d4d349b59a341f43ccf061904a17dd7c0d89 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Tue, 20 May 2025 15:20:09 +0100 Subject: [PATCH 2/7] fix: add attachment unit tests --- api/src/chat/services/block.service.spec.ts | 193 ++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 1c912766..2d1a7137 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -945,6 +945,199 @@ describe('BlockService', () => { }); }); + it('should return an attachment envelope when payload has an id', async () => { + const block: Block = { + id: 'b4', + message: { + attachment: { + type: FileType.image, + payload: { id: 'ABC123' }, + }, + }, + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: {}, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + false, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.attachment, + message: { + attachment: { + type: 'image', + payload: { id: 'ABC123' }, + }, + }, + }); + }); + + it('should return an attachment envelope when payload has an id (local fallback)', async () => { + const block: Block = { + id: 'b4', + message: { + attachment: { + type: FileType.image, + payload: { id: 'ABC123' }, + }, + quickReplies: [], + }, + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: { + fallback: { + active: true, + max_attempts: 1, + message: ['Local fallback ...'], + }, + }, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + true, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.text, + message: { + text: 'Local fallback ...', + }, + }); + }); + + it('should keep quickReplies when present in an attachment block', async () => { + const block: Block = { + id: 'b5', + message: { + attachment: { + type: FileType.video, + payload: { id: 'VID42' }, + }, + quickReplies: [ + { + content_type: QuickReplyType.text, + title: 'Replay', + payload: 'REPLAY', + }, + { + content_type: QuickReplyType.text, + title: 'Next', + payload: 'NEXT', + }, + ], + }, + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: {}, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + false, + conversationId, + ); + expect(env).toEqual({ + format: OutgoingMessageFormat.attachment, + message: { + attachment: { + type: FileType.video, + payload: { + id: 'VID42', + }, + }, + quickReplies: [ + { + content_type: QuickReplyType.text, + title: 'Replay', + payload: 'REPLAY', + }, + { + content_type: QuickReplyType.text, + title: 'Next', + payload: 'NEXT', + }, + ], + }, + }); + }); + + it('should throw when attachment payload misses an id (remote URLs deprecated)', async () => { + const spyCheckDeprecated = jest + .spyOn(blockService as any, 'checkDeprecatedAttachmentUrl') + .mockImplementation(() => {}); + + const block: any = { + id: 'b6', + message: { + attachment: { + type: 'image', + payload: { url: 'https://example.com/old-way.png' }, // no "id" + }, + }, + }; + + await expect( + blockService.processMessage(block, ctx, subCtx, false, conversationId), + ).rejects.toThrow( + 'Remote attachments in blocks are no longer supported!', + ); + + expect(spyCheckDeprecated).toHaveBeenCalledTimes(1); + + spyCheckDeprecated.mockRestore(); + }); + it('should process list message (with limit = 2 and skip = 0)', async () => { const contentType = (await contentTypeService.findOne({ name: 'Product', From 0163b070545a9495815f79f9a6e45a04af97489d Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Tue, 20 May 2025 15:38:29 +0100 Subject: [PATCH 3/7] feat: refactor and use envelope factory --- api/src/chat/services/block.service.ts | 127 ++++++++----------------- 1 file changed, 40 insertions(+), 87 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 569c75cc..dbc3cc29 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -42,7 +42,7 @@ import { StdOutgoingSystemEnvelope, } from '../schemas/types/message'; import { NlpPattern, PayloadPattern } from '../schemas/types/pattern'; -import { Payload, StdQuickReply } from '../schemas/types/quick-reply'; +import { Payload } from '../schemas/types/quick-reply'; import { SubscriberContext } from '../schemas/types/subscriberContext'; @Injectable() @@ -599,7 +599,7 @@ export class BlockService extends BaseService< * * @param block - The block holding the message to process * @param context - Context object - * @param fallback - Whenever to process main message or local fallback message + * @param isLocalFallback - Whenever to process main message or local fallback message * @param conversationId - The conversation ID * * @returns - Envelope containing message format and content following {format, message} object structure @@ -608,12 +608,23 @@ export class BlockService extends BaseService< block: Block | BlockFull, context: Context, subscriberContext: SubscriberContext, - fallback = false, + isLocalFallback = false, conversationId?: string, ): Promise { const settings = await this.settingService.getSettings(); + const envelopeFactory = new EnvelopeFactory( + { + ...context, + vars: { + ...context.vars, + ...subscriberContext.vars, + }, + }, + settings, + this.i18n, + ); const blockMessage: BlockMessage = - fallback && block.options?.fallback + isLocalFallback && block.options?.fallback ? [...block.options.fallback.message] : Array.isArray(block.message) ? [...block.message] @@ -621,79 +632,26 @@ export class BlockService extends BaseService< if (Array.isArray(blockMessage)) { // Text Message - // Get random message from array - const text = this.processText( - getRandomElement(blockMessage), - context, - subscriberContext, - settings, - ); - const envelope: StdOutgoingEnvelope = { - format: OutgoingMessageFormat.text, - message: { text }, - }; - return envelope; + return envelopeFactory.buildTextEnvelope(blockMessage); } else if (blockMessage && 'text' in blockMessage) { if ( 'quickReplies' in blockMessage && Array.isArray(blockMessage.quickReplies) && blockMessage.quickReplies.length > 0 ) { - const envelope: StdOutgoingEnvelope = { - format: OutgoingMessageFormat.quickReplies, - message: { - text: this.processText( - blockMessage.text, - context, - subscriberContext, - settings, - ), - quickReplies: blockMessage.quickReplies.map((qr: StdQuickReply) => { - return qr.title - ? { - ...qr, - title: this.processText( - qr.title, - context, - subscriberContext, - settings, - ), - } - : qr; - }), - }, - }; - return envelope; + return envelopeFactory.buildQuickRepliesEnvelope( + blockMessage.text, + blockMessage.quickReplies, + ); } else if ( 'buttons' in blockMessage && Array.isArray(blockMessage.buttons) && blockMessage.buttons.length > 0 ) { - const envelope: StdOutgoingEnvelope = { - format: OutgoingMessageFormat.buttons, - message: { - text: this.processText( - blockMessage.text, - context, - subscriberContext, - settings, - ), - buttons: blockMessage.buttons.map((btn) => { - return btn.title - ? { - ...btn, - title: this.processText( - btn.title, - context, - subscriberContext, - settings, - ), - } - : btn; - }), - }, - }; - return envelope; + return envelopeFactory.buildButtonsEnvelope( + blockMessage.text, + blockMessage.buttons, + ); } } else if (blockMessage && 'attachment' in blockMessage) { const attachmentPayload = blockMessage.attachment.payload; @@ -704,19 +662,13 @@ export class BlockService extends BaseService< ); } - const envelope: StdOutgoingEnvelope = { - format: OutgoingMessageFormat.attachment, - message: { - attachment: { - type: blockMessage.attachment.type, - payload: blockMessage.attachment.payload, - }, - quickReplies: blockMessage.quickReplies - ? [...blockMessage.quickReplies] - : undefined, + return envelopeFactory.buildAttachmentEnvelope( + { + type: blockMessage.attachment.type, + payload: blockMessage.attachment.payload, }, - }; - return envelope; + blockMessage.quickReplies ? [...blockMessage.quickReplies] : undefined, + ); } else if ( blockMessage && 'elements' in blockMessage && @@ -734,18 +686,19 @@ export class BlockService extends BaseService< } // Populate list with content try { - const results = await this.contentService.getContent( + const { elements, pagination } = await this.contentService.getContent( contentBlockOptions, skip, ); - const envelope: StdOutgoingEnvelope = { - format: contentBlockOptions.display, - message: { - ...results, - options: contentBlockOptions, - }, - }; - return envelope; + + return envelopeFactory.buildListEnvelope( + contentBlockOptions.display as + | OutgoingMessageFormat.list + | OutgoingMessageFormat.carousel, + contentBlockOptions, + elements, + pagination, + ); } catch (err) { this.logger.error( 'Unable to retrieve content for list template process', From 94cb9d2a7c73d0ee3d37c8c1d9b637c2c7aea094 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Tue, 20 May 2025 15:57:32 +0100 Subject: [PATCH 4/7] feat: adjust local fallback --- api/src/chat/services/block.service.spec.ts | 20 ++++- api/src/chat/services/block.service.ts | 94 ++++++++++++--------- 2 files changed, 70 insertions(+), 44 deletions(-) diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 2d1a7137..627979a9 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -832,9 +832,13 @@ describe('BlockService', () => { ); expect(env).toEqual({ - format: OutgoingMessageFormat.text, + format: OutgoingMessageFormat.quickReplies, message: { text: 'Local fallback message ...', + quickReplies: [ + { content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' }, + { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, + ], }, }); }); @@ -938,9 +942,21 @@ describe('BlockService', () => { ); expect(env).toEqual({ - format: OutgoingMessageFormat.text, + format: OutgoingMessageFormat.buttons, message: { text: 'Local fallback message ...', + buttons: [ + { + type: ButtonType.postback, + title: 'Red', + payload: 'RED', + }, + { + type: ButtonType.postback, + title: 'Green', + payload: 'GREEN', + }, + ], }, }); }); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index dbc3cc29..4cc05513 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -36,7 +36,6 @@ import { Label } from '../schemas/label.schema'; import { Subscriber } from '../schemas/subscriber.schema'; import { Context } from '../schemas/types/context'; import { - BlockMessage, OutgoingMessageFormat, StdOutgoingEnvelope, StdOutgoingSystemEnvelope, @@ -623,55 +622,64 @@ export class BlockService extends BaseService< settings, this.i18n, ); - const blockMessage: BlockMessage = - isLocalFallback && block.options?.fallback - ? [...block.options.fallback.message] - : Array.isArray(block.message) - ? [...block.message] - : { ...block.message }; + const fallback = isLocalFallback ? block.options.fallback : undefined; - if (Array.isArray(blockMessage)) { + if (Array.isArray(block.message)) { // Text Message - return envelopeFactory.buildTextEnvelope(blockMessage); - } else if (blockMessage && 'text' in blockMessage) { + return envelopeFactory.buildTextEnvelope( + fallback ? fallback.message : block.message, + ); + } else if ('text' in block.message) { if ( - 'quickReplies' in blockMessage && - Array.isArray(blockMessage.quickReplies) && - blockMessage.quickReplies.length > 0 + 'quickReplies' in block.message && + Array.isArray(block.message.quickReplies) && + block.message.quickReplies.length > 0 ) { return envelopeFactory.buildQuickRepliesEnvelope( - blockMessage.text, - blockMessage.quickReplies, + fallback ? fallback.message : block.message.text, + block.message.quickReplies, ); } else if ( - 'buttons' in blockMessage && - Array.isArray(blockMessage.buttons) && - blockMessage.buttons.length > 0 + 'buttons' in block.message && + Array.isArray(block.message.buttons) && + block.message.buttons.length > 0 ) { return envelopeFactory.buildButtonsEnvelope( - blockMessage.text, - blockMessage.buttons, + fallback ? fallback.message : block.message.text, + block.message.buttons, ); } - } else if (blockMessage && 'attachment' in blockMessage) { - const attachmentPayload = blockMessage.attachment.payload; + } else if ('attachment' in block.message) { + const attachmentPayload = block.message.attachment.payload; if (!('id' in attachmentPayload)) { this.checkDeprecatedAttachmentUrl(block); throw new Error( 'Remote attachments in blocks are no longer supported!', ); } + const quickReplies = block.message.quickReplies + ? [...block.message.quickReplies] + : []; - return envelopeFactory.buildAttachmentEnvelope( - { - type: blockMessage.attachment.type, - payload: blockMessage.attachment.payload, - }, - blockMessage.quickReplies ? [...blockMessage.quickReplies] : undefined, - ); + if (fallback) { + return quickReplies.length > 0 + ? envelopeFactory.buildQuickRepliesEnvelope( + fallback.message, + block.message.quickReplies ? [...block.message.quickReplies] : [], + ) + : envelopeFactory.buildTextEnvelope(fallback.message); + } else { + return envelopeFactory.buildAttachmentEnvelope( + { + type: block.message.attachment.type, + payload: block.message.attachment.payload, + }, + block.message.quickReplies ? [...block.message.quickReplies] : [], + ); + } } else if ( - blockMessage && - 'elements' in blockMessage && + block.message && + 'elements' in block.message && block.options?.content ) { const contentBlockOptions = block.options.content; @@ -691,14 +699,16 @@ export class BlockService extends BaseService< skip, ); - return envelopeFactory.buildListEnvelope( - contentBlockOptions.display as - | OutgoingMessageFormat.list - | OutgoingMessageFormat.carousel, - contentBlockOptions, - elements, - pagination, - ); + return fallback + ? envelopeFactory.buildTextEnvelope(fallback.message) + : envelopeFactory.buildListEnvelope( + contentBlockOptions.display as + | OutgoingMessageFormat.list + | OutgoingMessageFormat.carousel, + contentBlockOptions, + elements, + pagination, + ); } catch (err) { this.logger.error( 'Unable to retrieve content for list template process', @@ -706,10 +716,10 @@ export class BlockService extends BaseService< ); throw err; } - } else if (blockMessage && 'plugin' in blockMessage) { + } else if (block.message && 'plugin' in block.message) { const plugin = this.pluginService.findPlugin( PluginType.block, - blockMessage.plugin, + block.message.plugin, ); // Process custom plugin block try { @@ -722,7 +732,7 @@ export class BlockService extends BaseService< return envelope; } catch (e) { this.logger.error('Plugin was unable to load/process ', e); - throw new Error(`Unknown plugin - ${JSON.stringify(blockMessage)}`); + throw new Error(`Unknown plugin - ${JSON.stringify(block.message)}`); } } throw new Error('Invalid message format.'); From 409f397454b967d5e469b3160942a2d91ce8bc56 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Tue, 20 May 2025 16:10:50 +0100 Subject: [PATCH 5/7] fix: apply nit picks --- api/src/chat/services/block.service.spec.ts | 220 +++++--------------- api/src/chat/services/block.service.ts | 17 +- 2 files changed, 55 insertions(+), 182 deletions(-) diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 627979a9..b945cacc 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -97,6 +97,30 @@ import { CategoryRepository } from './../repositories/category.repository'; import { BlockService } from './block.service'; import { CategoryService } from './category.service'; +function makeMockBlock(overrides: Partial): Block { + return { + id: 'default', + message: [], + trigger_labels: [], + assign_labels: [], + nextBlocks: [], + attachedBlock: null, + category: null, + name: '', + patterns: [], + outcomes: [], + trigger_channels: [], + options: {}, + starts_conversation: false, + capture_vars: [], + position: { x: 0, y: 0 }, + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + describe('BlockService', () => { let blockRepository: BlockRepository; let categoryRepository: CategoryRepository; @@ -321,7 +345,7 @@ describe('BlockService', () => { }); describe('matchNLP', () => { - it('should return undefined for match nlp against a block with no patterns', () => { + it('should return an empty array for a block with no NLP patterns', () => { const result = blockService.getMatchingNluPatterns( mockNlpGreetingFullNameEntities, blockEmpty, @@ -329,7 +353,7 @@ describe('BlockService', () => { expect(result).toEqual([]); }); - it('should return undefined for match nlp when no nlp entities are provided', () => { + it('should return an empty array when no NLP entities are provided', () => { const result = blockService.getMatchingNluPatterns( { entities: [] }, blockGetStarted, @@ -658,28 +682,11 @@ describe('BlockService', () => { const conversationId = 'conv-id'; it('should return a text envelope when the block is a text block', async () => { - const block: Block = { - id: 'b1', + const block = makeMockBlock({ message: [ 'Hello {{context.user.first_name}}, your phone is {{context.vars.phone}} and your favorite color is {{context.vars.color}}', ], - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], - options: {}, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -698,8 +705,7 @@ describe('BlockService', () => { }); it('should return a text envelope when the block is a text block (local fallback)', async () => { - const block: Block = { - id: 'b1', + const block = makeMockBlock({ message: ['Hello world!'], options: { fallback: { @@ -708,22 +714,7 @@ describe('BlockService', () => { message: ['Local fallback message ...'], }, }, - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -742,8 +733,7 @@ describe('BlockService', () => { }); it('should return a quick replies envelope when the block message has quickReplies', async () => { - const block: Block = { - id: 'b2', + const block = makeMockBlock({ message: { text: '{{context.user.first_name}}, is this your phone number? {{context.vars.phone}}', quickReplies: [ @@ -751,23 +741,7 @@ describe('BlockService', () => { { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, ], }, - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], - options: {}, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -790,8 +764,7 @@ describe('BlockService', () => { }); it('should return a quick replies envelope when the block message has quickReplies (local fallback)', async () => { - const block: Block = { - id: 'b2', + const block = makeMockBlock({ message: { text: '{{context.user.first_name}}, are you there?', quickReplies: [ @@ -799,15 +772,6 @@ describe('BlockService', () => { { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, ], }, - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], options: { fallback: { active: true, @@ -815,13 +779,7 @@ describe('BlockService', () => { message: ['Local fallback message ...'], }, }, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -844,8 +802,7 @@ describe('BlockService', () => { }); it('should return a buttons envelope when the block message has buttons', async () => { - const block: Block = { - id: 'b3', + const block = makeMockBlock({ message: { text: '{{context.user.first_name}} {{context.user.last_name}}, what color do you like? {{context.vars.color}}?', buttons: [ @@ -853,23 +810,7 @@ describe('BlockService', () => { { type: ButtonType.postback, title: 'Green', payload: 'GREEN' }, ], }, - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], - options: {}, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -900,8 +841,7 @@ describe('BlockService', () => { }); it('should return a buttons envelope when the block message has buttons (local fallback)', async () => { - const block: Block = { - id: 'b3', + const block = makeMockBlock({ message: { text: '{{context.user.first_name}} {{context.user.last_name}}, what color do you like? {{context.vars.color}}?', buttons: [ @@ -909,15 +849,6 @@ describe('BlockService', () => { { type: ButtonType.postback, title: 'Green', payload: 'GREEN' }, ], }, - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], options: { fallback: { active: true, @@ -925,13 +856,7 @@ describe('BlockService', () => { message: ['Local fallback message ...'], }, }, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -962,31 +887,14 @@ describe('BlockService', () => { }); it('should return an attachment envelope when payload has an id', async () => { - const block: Block = { - id: 'b4', + const block = makeMockBlock({ message: { attachment: { type: FileType.image, payload: { id: 'ABC123' }, }, }, - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], - options: {}, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -1008,8 +916,7 @@ describe('BlockService', () => { }); it('should return an attachment envelope when payload has an id (local fallback)', async () => { - const block: Block = { - id: 'b4', + const block = makeMockBlock({ message: { attachment: { type: FileType.image, @@ -1017,15 +924,6 @@ describe('BlockService', () => { }, quickReplies: [], }, - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], options: { fallback: { active: true, @@ -1033,13 +931,7 @@ describe('BlockService', () => { message: ['Local fallback ...'], }, }, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -1058,8 +950,7 @@ describe('BlockService', () => { }); it('should keep quickReplies when present in an attachment block', async () => { - const block: Block = { - id: 'b5', + const block = makeMockBlock({ message: { attachment: { type: FileType.video, @@ -1078,23 +969,7 @@ describe('BlockService', () => { }, ], }, - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], - options: {}, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }; + }); const env = await blockService.processMessage( block, @@ -1133,15 +1008,14 @@ describe('BlockService', () => { .spyOn(blockService as any, 'checkDeprecatedAttachmentUrl') .mockImplementation(() => {}); - const block: any = { - id: 'b6', + const block = makeMockBlock({ message: { attachment: { - type: 'image', + type: FileType.image, payload: { url: 'https://example.com/old-way.png' }, // no "id" }, }, - }; + }); await expect( blockService.processMessage(block, ctx, subCtx, false, conversationId), diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 4cc05513..231516d3 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -622,7 +622,7 @@ export class BlockService extends BaseService< settings, this.i18n, ); - const fallback = isLocalFallback ? block.options.fallback : undefined; + const fallback = isLocalFallback ? block.options?.fallback : undefined; if (Array.isArray(block.message)) { // Text Message @@ -668,15 +668,14 @@ export class BlockService extends BaseService< block.message.quickReplies ? [...block.message.quickReplies] : [], ) : envelopeFactory.buildTextEnvelope(fallback.message); - } else { - return envelopeFactory.buildAttachmentEnvelope( - { - type: block.message.attachment.type, - payload: block.message.attachment.payload, - }, - block.message.quickReplies ? [...block.message.quickReplies] : [], - ); } + return envelopeFactory.buildAttachmentEnvelope( + { + type: block.message.attachment.type, + payload: block.message.attachment.payload, + }, + block.message.quickReplies ? [...block.message.quickReplies] : [], + ); } else if ( block.message && 'elements' in block.message && From 1f7a8bd62532ea0ff6a57e822b14ebdc5ae175bf Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Tue, 20 May 2025 16:15:04 +0100 Subject: [PATCH 6/7] fix: reuse quickReplies --- api/src/chat/services/block.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 231516d3..e6d5b88d 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -665,7 +665,7 @@ export class BlockService extends BaseService< return quickReplies.length > 0 ? envelopeFactory.buildQuickRepliesEnvelope( fallback.message, - block.message.quickReplies ? [...block.message.quickReplies] : [], + quickReplies, ) : envelopeFactory.buildTextEnvelope(fallback.message); } @@ -674,7 +674,7 @@ export class BlockService extends BaseService< type: block.message.attachment.type, payload: block.message.attachment.payload, }, - block.message.quickReplies ? [...block.message.quickReplies] : [], + quickReplies, ); } else if ( block.message && From 031e54ef32aa8bb8a93991eefad7a5045ed6e988 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Tue, 20 May 2025 16:36:14 +0100 Subject: [PATCH 7/7] fix: add missing fallback for plugins --- api/src/chat/services/block.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index e6d5b88d..47bc9cc1 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -716,6 +716,10 @@ export class BlockService extends BaseService< throw err; } } else if (block.message && 'plugin' in block.message) { + if (fallback) { + return envelopeFactory.buildTextEnvelope(fallback.message); + } + const plugin = this.pluginService.findPlugin( PluginType.block, block.message.plugin, @@ -731,7 +735,7 @@ export class BlockService extends BaseService< return envelope; } catch (e) { this.logger.error('Plugin was unable to load/process ', e); - throw new Error(`Unknown plugin - ${JSON.stringify(block.message)}`); + throw new Error(`Plugin Error - ${JSON.stringify(block.message)}`); } } throw new Error('Invalid message format.');