diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index cc94f34e..b945cacc 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,13 +84,43 @@ 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'; 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; @@ -315,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, @@ -323,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, @@ -630,6 +660,374 @@ 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 = makeMockBlock({ + message: [ + 'Hello {{context.user.first_name}}, your phone is {{context.vars.phone}} and your favorite color is {{context.vars.color}}', + ], + }); + + 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 = makeMockBlock({ + message: ['Hello world!'], + options: { + fallback: { + active: true, + max_attempts: 1, + message: ['Local fallback message ...'], + }, + }, + }); + + 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 = makeMockBlock({ + 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' }, + ], + }, + }); + + 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 = makeMockBlock({ + 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' }, + ], + }, + options: { + fallback: { + active: true, + max_attempts: 1, + message: ['Local fallback message ...'], + }, + }, + }); + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + true, + conversationId, + ); + + expect(env).toEqual({ + 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' }, + ], + }, + }); + }); + + it('should return a buttons envelope when the block message has buttons', async () => { + const block = makeMockBlock({ + 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' }, + ], + }, + }); + + 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 = makeMockBlock({ + 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' }, + ], + }, + options: { + fallback: { + active: true, + max_attempts: 1, + message: ['Local fallback message ...'], + }, + }, + }); + + const env = await blockService.processMessage( + block, + ctx, + subCtx, + true, + conversationId, + ); + + expect(env).toEqual({ + format: OutgoingMessageFormat.buttons, + message: { + text: 'Local fallback message ...', + buttons: [ + { + type: ButtonType.postback, + title: 'Red', + payload: 'RED', + }, + { + type: ButtonType.postback, + title: 'Green', + payload: 'GREEN', + }, + ], + }, + }); + }); + + it('should return an attachment envelope when payload has an id', async () => { + const block = makeMockBlock({ + message: { + attachment: { + type: FileType.image, + payload: { id: 'ABC123' }, + }, + }, + }); + + 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 = makeMockBlock({ + message: { + attachment: { + type: FileType.image, + payload: { id: 'ABC123' }, + }, + quickReplies: [], + }, + options: { + fallback: { + active: true, + max_attempts: 1, + message: ['Local fallback ...'], + }, + }, + }); + + 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 = makeMockBlock({ + 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', + }, + ], + }, + }); + + 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 = makeMockBlock({ + message: { + attachment: { + type: FileType.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', diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 569c75cc..47bc9cc1 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -36,13 +36,12 @@ import { Label } from '../schemas/label.schema'; import { Subscriber } from '../schemas/subscriber.schema'; import { Context } from '../schemas/types/context'; import { - BlockMessage, OutgoingMessageFormat, StdOutgoingEnvelope, 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 +598,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,118 +607,78 @@ 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 blockMessage: BlockMessage = - fallback && block.options?.fallback - ? [...block.options.fallback.message] - : Array.isArray(block.message) - ? [...block.message] - : { ...block.message }; + const envelopeFactory = new EnvelopeFactory( + { + ...context, + vars: { + ...context.vars, + ...subscriberContext.vars, + }, + }, + settings, + this.i18n, + ); + const fallback = isLocalFallback ? block.options?.fallback : undefined; - if (Array.isArray(blockMessage)) { + if (Array.isArray(block.message)) { // Text Message - // Get random message from array - const text = this.processText( - getRandomElement(blockMessage), - context, - subscriberContext, - settings, + return envelopeFactory.buildTextEnvelope( + fallback ? fallback.message : block.message, ); - const envelope: StdOutgoingEnvelope = { - format: OutgoingMessageFormat.text, - message: { text }, - }; - return envelope; - } else if (blockMessage && 'text' in blockMessage) { + } 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 ) { - 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( + 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 ) { - 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( + 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] + : []; - const envelope: StdOutgoingEnvelope = { - format: OutgoingMessageFormat.attachment, - message: { - attachment: { - type: blockMessage.attachment.type, - payload: blockMessage.attachment.payload, - }, - quickReplies: blockMessage.quickReplies - ? [...blockMessage.quickReplies] - : undefined, + if (fallback) { + return quickReplies.length > 0 + ? envelopeFactory.buildQuickRepliesEnvelope( + fallback.message, + quickReplies, + ) + : envelopeFactory.buildTextEnvelope(fallback.message); + } + return envelopeFactory.buildAttachmentEnvelope( + { + type: block.message.attachment.type, + payload: block.message.attachment.payload, }, - }; - return envelope; + quickReplies, + ); } else if ( - blockMessage && - 'elements' in blockMessage && + block.message && + 'elements' in block.message && block.options?.content ) { const contentBlockOptions = block.options.content; @@ -734,18 +693,21 @@ 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 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', @@ -753,10 +715,14 @@ export class BlockService extends BaseService< ); throw err; } - } else if (blockMessage && 'plugin' in blockMessage) { + } else if (block.message && 'plugin' in block.message) { + if (fallback) { + return envelopeFactory.buildTextEnvelope(fallback.message); + } + const plugin = this.pluginService.findPlugin( PluginType.block, - blockMessage.plugin, + block.message.plugin, ); // Process custom plugin block try { @@ -769,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(blockMessage)}`); + throw new Error(`Plugin Error - ${JSON.stringify(block.message)}`); } } throw new Error('Invalid message format.');