Merge pull request #1020 from Hexastack/feat/local-fallback-qr-btns
Some checks are pending
Build and Push Docker API Image / build-and-push (push) Waiting to run
Build and Push Docker Base Image / build-and-push (push) Waiting to run
Build and Push Docker UI Image / build-and-push (push) Waiting to run

Feat/local fallback for quick replies and buttons blocks
This commit is contained in:
Med Marrouchi
2025-05-20 17:39:35 +01:00
committed by GitHub
2 changed files with 475 additions and 111 deletions

View File

@@ -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>): 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',

View File

@@ -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<StdOutgoingEnvelope> {
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.');