feat: envelope factory

This commit is contained in:
Mohamed Marrouchi 2025-03-23 21:31:18 +01:00
parent e0a77302cc
commit 10b39529b9
10 changed files with 979 additions and 298 deletions

View File

@ -0,0 +1,121 @@
/*
* 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 { z } from 'zod';
import {
OutgoingMessageFormat,
StdOutgoingQuickRepliesEnvelope,
stdOutgoingQuickRepliesEnvelopeSchema,
StdOutgoingTextEnvelope,
stdOutgoingTextEnvelopeSchema,
stdOutgoingTextMessageSchema,
} from '../schemas/types/message';
import { QuickReplyType } from '../schemas/types/quick-reply';
import { EnvelopeBuilder, getEnvelopeBuilder } from './envelope-builder';
describe('EnvelopeBuilder', () => {
it('should create a builder with chainable setters', () => {
const builder = EnvelopeBuilder<StdOutgoingTextEnvelope>(
OutgoingMessageFormat.text,
{},
stdOutgoingTextEnvelopeSchema,
);
builder.text('Hello');
const result = builder.build();
expect(result).toEqual({
format: OutgoingMessageFormat.text,
message: {
text: 'Hello',
},
});
});
it('should retrieve current field values when no argument is provided', () => {
const builder = EnvelopeBuilder<StdOutgoingTextEnvelope>(
OutgoingMessageFormat.text,
{},
stdOutgoingTextEnvelopeSchema,
);
builder.text('Hello world');
// Retrieve current value with no argument
expect(builder.text()).toBe('Hello world');
});
it('should append items to array fields with appendToX methods', () => {
const builder = EnvelopeBuilder<StdOutgoingQuickRepliesEnvelope>(
OutgoingMessageFormat.quickReplies,
{},
stdOutgoingQuickRepliesEnvelopeSchema,
);
builder.text('Choose an option');
builder.appendToQuickReplies({
content_type: QuickReplyType.text,
title: 'Yes',
payload: 'yes',
});
builder.appendToQuickReplies({
content_type: QuickReplyType.text,
title: 'No',
payload: 'no',
});
const result = builder.build();
expect(result).toEqual({
format: OutgoingMessageFormat.quickReplies,
message: {
text: 'Choose an option',
quickReplies: [
{ content_type: 'text', title: 'Yes', payload: 'yes' },
{ content_type: 'text', title: 'No', payload: 'no' },
],
},
});
});
it('should validate the final envelope on build and throw on invalid data', () => {
const builder = EnvelopeBuilder(
OutgoingMessageFormat.text,
{},
stdOutgoingTextMessageSchema,
);
expect(() => builder.build()).toThrow(z.ZodError);
});
});
describe('getEnvelopeBuilder', () => {
it('should return a builder for text format that passes validation with required field', () => {
const builder = getEnvelopeBuilder(OutgoingMessageFormat.text);
builder.text('Hello from text envelope!');
const envelope = builder.build();
expect(envelope.format).toBe(OutgoingMessageFormat.text);
expect(envelope.message.text).toBe('Hello from text envelope!');
});
it('should return a builder for quickReplies format that can append items', () => {
const builder = getEnvelopeBuilder(OutgoingMessageFormat.quickReplies);
builder.text('Pick an option');
builder.appendToQuickReplies({
content_type: QuickReplyType.text,
title: 'Option A',
payload: 'a',
});
const envelope = builder.build();
expect(envelope.format).toBe(OutgoingMessageFormat.quickReplies);
expect(envelope.message.text).toBe('Pick an option');
expect(envelope.message.quickReplies?.length).toBe(1);
});
});

View File

@ -0,0 +1,190 @@
/*
* 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 { z } from 'zod';
import {
OutgoingMessageFormat,
StdOutgoingAttachmentEnvelope,
stdOutgoingAttachmentEnvelopeSchema,
StdOutgoingButtonsEnvelope,
stdOutgoingButtonsEnvelopeSchema,
StdOutgoingEnvelope,
StdOutgoingListEnvelope,
stdOutgoingListEnvelopeSchema,
StdOutgoingMessageEnvelope,
StdOutgoingQuickRepliesEnvelope,
stdOutgoingQuickRepliesEnvelopeSchema,
StdOutgoingSystemEnvelope,
stdOutgoingSystemEnvelopeSchema,
StdOutgoingTextEnvelope,
stdOutgoingTextEnvelopeSchema,
} from '../schemas/types/message';
type ArrayKeys<T> = {
[K in keyof T]: NonNullable<T[K]> extends Array<any> ? K : never;
}[keyof T];
export type IEnvelopeBuilder<T extends StdOutgoingEnvelope> = {
[k in keyof T['message']]-?: ((arg: T['message'][k]) => IEnvelopeBuilder<T>) &
(() => T['message'][k]);
} & {
[K in ArrayKeys<T['message']> as `appendTo${Capitalize<string & K>}`]: (
item: NonNullable<T['message'][K]> extends (infer U)[] ? U : never,
) => IEnvelopeBuilder<T>;
} & {
build(): T;
};
/**
* Builds an envelope object (containing a `format` and a `message` property)
* and returns a proxy-based builder interface with chainable setter methods.
* It also validates the final envelope against the provided `z.ZodSchema`.
*
* @param format - The format of the outgoing envelope.
* Corresponds to `format` on the generic type `T`.
* @param template - An optional initial message template.
* It will be merged as you set or append properties through the returned builder.
* @param schema - A Zod schema used to validate the final envelope object.
* @param factory - Envelope Factory which provides methods common methods.
*
* @returns A proxy-based builder object implementing `IEnvelopeBuilder<T>`. It provides
* chainable setter methods for all message fields, an `appendToX` pattern for
* array fields, and a `build()` method to finalize and validate the envelope.
*
* @example
* // Build a simple text envelope:
* const env1 = EnvelopeBuilder(OutgoingMessageFormat.text)
* .text('Hello')
* .build();
*
* @example
* // Build a text envelope with quick replies:
* const env2 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies)
* .text('Hello')
* .quickReplies([])
* .build();
*
* @example
* // Append multiple quickReplies items:
* const env3 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies)
* .text('Are you interested?')
* .appendToQuickReplies({
* content_type: QuickReplyType.text,
* title: 'Yes',
* payload: 'yes',
* })
* .appendToQuickReplies({
* content_type: QuickReplyType.text,
* title: 'No',
* payload: 'no',
* })
* .build();
*
* @example
* // Build a system envelope with an outcome:
* const env4 = EnvelopeBuilder(OutgoingMessageFormat.system)
* .outcome('success')
* .build();
*/
export function EnvelopeBuilder<T extends StdOutgoingEnvelope>(
format: T['format'],
template: Partial<T['message']> = {},
schema: z.ZodSchema,
): IEnvelopeBuilder<T> {
let built: { format: T['format']; message: Partial<T['message']> } = {
format,
message: template,
};
const builder = new Proxy(
{},
{
get(target, prop) {
if ('build' === prop) {
// No type information - just return the object.
return () => {
const result = schema.parse(built);
built = {
format,
message: template,
};
return result;
};
}
if (typeof prop === 'string' && prop.startsWith('appendTo')) {
// e.g. "appendToButtons" => "Buttons"
const rawKey = prop.replace(/^appendTo/, '');
// e.g. "Buttons" -> "buttons"
const messageKey = rawKey.charAt(0).toLowerCase() + rawKey.slice(1);
return (item: unknown) => {
// Initialize the array if needed
if (!Array.isArray(built.message[messageKey])) {
built.message[messageKey] = [];
}
(built.message[messageKey] as unknown[]).push(item);
return builder;
};
}
return (...args: unknown[]): unknown => {
// If no arguments passed return current value.
if (0 === args.length) {
return built.message[prop.toString()];
}
const value = args[0];
built.message[prop.toString()] = value;
return builder;
};
},
},
);
return builder as IEnvelopeBuilder<T>;
}
type EnvelopeTypeByFormat<F extends OutgoingMessageFormat> =
F extends OutgoingMessageFormat.text
? StdOutgoingTextEnvelope
: F extends OutgoingMessageFormat.quickReplies
? StdOutgoingQuickRepliesEnvelope
: F extends OutgoingMessageFormat.buttons
? StdOutgoingButtonsEnvelope
: F extends OutgoingMessageFormat.attachment
? StdOutgoingAttachmentEnvelope
: F extends OutgoingMessageFormat.carousel
? StdOutgoingListEnvelope
: F extends OutgoingMessageFormat.list
? StdOutgoingListEnvelope
: F extends OutgoingMessageFormat.system
? StdOutgoingSystemEnvelope
: StdOutgoingMessageEnvelope;
const ENVELOP_SCHEMAS_BY_FORMAT = {
[OutgoingMessageFormat.text]: stdOutgoingTextEnvelopeSchema,
[OutgoingMessageFormat.quickReplies]: stdOutgoingQuickRepliesEnvelopeSchema,
[OutgoingMessageFormat.buttons]: stdOutgoingButtonsEnvelopeSchema,
[OutgoingMessageFormat.attachment]: stdOutgoingAttachmentEnvelopeSchema,
[OutgoingMessageFormat.carousel]: stdOutgoingListEnvelopeSchema,
[OutgoingMessageFormat.list]: stdOutgoingListEnvelopeSchema,
[OutgoingMessageFormat.system]: stdOutgoingSystemEnvelopeSchema,
};
export const getEnvelopeBuilder = <F extends OutgoingMessageFormat>(
format: F,
) => {
return EnvelopeBuilder<EnvelopeTypeByFormat<F>>(
format,
{},
ENVELOP_SCHEMAS_BY_FORMAT[format],
);
};

View File

@ -0,0 +1,318 @@
/*
* 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 { AttachmentPayload, FileType } from '../schemas/types/attachment';
import { Button, ButtonType } from '../schemas/types/button';
import { Context } from '../schemas/types/context';
import {
ContentElement,
ContentPagination,
OutgoingMessageFormat,
} from '../schemas/types/message';
import { ContentOptions } from '../schemas/types/options';
import { StdQuickReply } from '../schemas/types/quick-reply';
import { EnvelopeFactory } from './envelope-factory';
// Mock getRandomElement to produce a predictable result in tests.
jest.mock('@/utils/helpers/safeRandom', () => ({
getRandomElement: (array) => (Array.isArray(array) ? array[0] : array),
}));
// Set up a dummy global Handlebars so that our template compilation works.
// This simple implementation replaces tokens of the form {{a.b.c}} by looking up the path.
(global as any).Handlebars = {
compile: (template: string) => (context: any) =>
template.replace(/\{\{([^}]+)\}\}/g, (_, token: string) => {
const parts = token.trim().split('.');
let value = context;
for (const part of parts) {
value = value[part];
}
return value;
}),
};
describe('EnvelopeFactory', () => {
let factory: EnvelopeFactory;
let context: Context;
let settings: Settings;
let i18n: { t: jest.Mock };
beforeEach(async () => {
context = {
user: { language: 'en', first_name: 'John', last_name: 'Doe', id: '123' },
vars: { phone: '123-456-7890' },
} as unknown as Context;
settings = {
contact: {
company_name: 'John Inc.',
company_email: 'contact@john-inc.com',
},
} as Settings;
// For testing, i18n.t simply returns the provided text unchanged.
i18n = { t: jest.fn((text: string) => text) };
factory = new EnvelopeFactory(context, settings, i18n as any);
});
describe('toHandlebars (static)', () => {
it('should convert single curly braces to double curly braces when no existing {{ }} are present', () => {
const input =
'Hello {context.user.name}, your phone is {context.vars.phone}';
// Access the private method using bracket notation
const result = EnvelopeFactory.toHandlebars(input);
expect(result).toBe(
'Hello {{context.user.name}}, your phone is {{context.vars.phone}}',
);
});
it('should leave strings that already contain double curly braces unchanged', () => {
const input =
'Hello {{context.user.name}}, your phone is {{context.vars.phone}}';
const result = EnvelopeFactory.toHandlebars(input);
expect(result).toBe(input);
});
it('should handle strings with no braces at all', () => {
const input = 'Hello world, no braces here';
const result = EnvelopeFactory.toHandlebars(input);
// Should be unchanged since there are no placeholders
expect(result).toBe(input);
});
it('should handle multiple single placeholders correctly', () => {
const input = '{one} {two} {three}';
const result = EnvelopeFactory.toHandlebars(input);
expect(result).toBe('{{one}} {{two}} {{three}}');
});
});
describe('compileHandlerbarsTemplate', () => {
it('should replace tokens with context variables correctly', () => {
const text =
'Hello {{context.user.first_name}} {{context.user.last_name}}, your phone is {{context.vars.phone}}';
const result = EnvelopeFactory.compileHandlerbarsTemplate(
text,
context,
settings,
);
// Expect that single curly braces got turned into Handlebars placeholders
// and then replaced with actual values from the merged context
expect(result).toBe('Hello John Doe, your phone is 123-456-7890');
});
it('should merge subscriberContext.vars and context.vars correctly', () => {
const text =
'Subscriber var: {context.vars.subscriberVar}, Context var: {context.vars.contextVar}';
const context = {
user: {},
vars: {
contextVar: 'ContextValue',
subscriberVar: 'SubscriberValue',
},
} as unknown as Context;
const settings = {
contact: {},
} as unknown as Settings;
const result = EnvelopeFactory.compileHandlerbarsTemplate(
text,
context,
settings,
);
expect(result).toBe(
'Subscriber var: SubscriberValue, Context var: ContextValue',
);
});
it('should use contact from settings if provided', () => {
const text = 'You can reach us at {{contact.company_email}}';
const result = EnvelopeFactory.compileHandlerbarsTemplate(
text,
context,
settings,
);
expect(result).toBe('You can reach us at contact@john-inc.com');
});
it('should handle no placeholders gracefully', () => {
const text = 'No placeholders here.';
const result = EnvelopeFactory.compileHandlerbarsTemplate(
text,
context,
settings,
);
expect(result).toBe('No placeholders here.');
});
});
describe('processText', () => {
it('should process text when a string is provided', () => {
const input = 'Hello {{context.user.first_name}}';
const result = factory.processText(input);
expect(result).toBe('Hello John');
expect(i18n.t).toHaveBeenCalledWith(input, {
lang: context.user.language,
defaultValue: input,
});
});
it('should process text when an array is provided (using the first element)', () => {
const texts = ['Option1 {{context.user.first_name}}', 'Option2'];
const result = factory.processText(texts);
expect(result).toBe('Option1 John');
});
});
describe('buildTextEnvelope', () => {
it('should build a text envelope with processed text', () => {
const input = 'Hello {{context.user.first_name}}';
const envelope = factory.buildTextEnvelope(input);
expect(envelope.format).toBe(OutgoingMessageFormat.text);
expect(envelope.message.text).toBe('Hello John');
});
});
describe('buildQuickRepliesEnvelope', () => {
it('should build a quick replies envelope with processed text and quick replies', () => {
const input = "Choose {{context.user.first_name}}'s option";
const quickReplies = [
{
content_type: 'text',
title: 'Yes {{contact.company_name}}',
payload: 'do_{{context.user.id}}',
},
{
content_type: 'text',
title: 'No {{contact.company_name}}',
payload: 'dont_{{context.user.id}}',
},
] as StdQuickReply[];
const envelope = factory.buildQuickRepliesEnvelope(input, quickReplies);
expect(envelope.format).toBe(OutgoingMessageFormat.quickReplies);
expect(envelope.message.text).toBe("Choose John's option");
expect(envelope.message.quickReplies).toHaveLength(2);
expect(envelope.message.quickReplies[0].title).toBe('Yes John Inc.');
expect(envelope.message.quickReplies[0].payload).toBe('do_123');
expect(envelope.message.quickReplies[1].title).toBe('No John Inc.');
expect(envelope.message.quickReplies[1].payload).toBe('dont_123');
});
});
describe('buildButtonsEnvelope', () => {
it('should build a buttons envelope with processed text and buttons', () => {
const input = 'Press a button';
const buttons: Button[] = [
{
type: ButtonType.postback,
title: 'Click {{contact.company_name}}',
payload: 'btn_{context.user.id}',
},
{
type: ButtonType.web_url,
title: 'Visit {{contact.company_name}}',
url: 'https://example.com',
},
];
const envelope = factory.buildButtonsEnvelope(input, buttons);
expect(envelope.format).toBe(OutgoingMessageFormat.buttons);
expect(envelope.message.text).toBe('Press a button');
expect(envelope.message.buttons).toHaveLength(2);
// For a postback button, both title and payload are processed.
expect(envelope.message.buttons[0].title).toBe('Click John Inc.');
// @ts-expect-error part of the test
expect(envelope.message.buttons[0].payload).toBe('btn_123');
// For a non-postback button, only the title is processed.
expect(envelope.message.buttons[1].title).toBe('Visit John Inc.');
// @ts-expect-error part of the test
expect(envelope.message.buttons[1].url).toBe('https://example.com');
});
});
describe('buildAttachmentEnvelope', () => {
it('should build an attachment envelope with the provided attachment and processed quick replies', () => {
const attachment = {
type: FileType.image,
payload: {
url: 'https://example.com/image.png',
},
} as AttachmentPayload;
const quickReplies = [
{
content_type: 'text',
title: 'Yes {contact.company_name}',
payload: 'do_{context.user.id}',
},
] as StdQuickReply[];
const envelope = factory.buildAttachmentEnvelope(
attachment,
quickReplies,
);
expect(envelope.format).toBe(OutgoingMessageFormat.attachment);
expect(envelope.message.attachment).toEqual(attachment);
expect(envelope.message.quickReplies).toHaveLength(1);
expect(envelope.message.quickReplies?.[0].title).toBe('Yes John Inc.');
expect(envelope.message.quickReplies?.[0].payload).toBe('do_123');
});
});
describe('buildListEnvelope', () => {
it('should build a list (or carousel) envelope with options, elements and pagination', () => {
const elements = [
{ id: '1', title: 'Element 1' },
{ id: '2', title: 'Element 2' },
{ id: '3', title: 'Element 3' },
] as ContentElement[];
// Test both carousel and list formats.
[OutgoingMessageFormat.carousel, OutgoingMessageFormat.list].forEach(
(format) => {
const options = {
buttons: [],
display: format,
limit: 3,
fields: {
title: 'title',
subtitle: '',
image_url: '',
},
} as unknown as ContentOptions;
const pagination = {
total: 3,
skip: 0,
limit: 3,
} as ContentPagination;
const envelope = factory.buildListEnvelope(
format as
| OutgoingMessageFormat.carousel
| OutgoingMessageFormat.list,
options,
elements,
pagination,
);
expect(envelope.format).toBe(format);
expect(envelope.message.options).toEqual(options);
expect(envelope.message.elements).toEqual(elements);
expect(envelope.message.pagination).toEqual(pagination);
},
);
});
});
describe('buildSystemEnvelope', () => {
it('should build a system envelope with outcome and data', () => {
const outcome = 'success';
const data = { key: 'value' };
const envelope = factory.buildSystemEnvelope(outcome, data);
expect(envelope.format).toBe(OutgoingMessageFormat.system);
expect(envelope.message.outcome).toBe(outcome);
expect(envelope.message.data).toEqual(data);
});
});
});

View File

@ -0,0 +1,271 @@
/*
* 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 { I18nService } from '@/i18n/services/i18n.service';
import { getRandomElement } from '@/utils/helpers/safeRandom';
import { AttachmentPayload } from '../schemas/types/attachment';
import { Button, ButtonType } from '../schemas/types/button';
import { Context, TemplateContext } from '../schemas/types/context';
import {
ContentElement,
ContentPagination,
OutgoingMessageFormat,
StdOutgoingAttachmentEnvelope,
StdOutgoingButtonsEnvelope,
StdOutgoingListEnvelope,
StdOutgoingQuickRepliesEnvelope,
StdOutgoingSystemEnvelope,
StdOutgoingTextEnvelope,
} from '../schemas/types/message';
import { ContentOptions } from '../schemas/types/options';
import { StdQuickReply } from '../schemas/types/quick-reply';
import { getEnvelopeBuilder } from './envelope-builder';
export class EnvelopeFactory {
constructor(
protected readonly context: Context,
protected readonly settings: Settings,
protected readonly i18n: I18nService,
) {}
/**
* Converts an old text template with single-curly placeholders, e.g. `{context.user.name}`,
* into a Handlebars-style template, e.g. `{{context.user.name}}`.
*
* @param str - The template string you want to convert.
* @returns The converted template string with Handlebars-style placeholders.
*/
static toHandlebars(str: string) {
// If the string already contains {{ }}, assume it's already a handlebars template.
if (/\{\{.*\}\}/.test(str)) {
return str;
}
// Otherwise, replace single curly braces { } with double curly braces {{ }}.
return str.replaceAll(/{([^}]+)}/g, '{{$1}}');
}
/**
* Compiles a handlebars template to replace tokens with their associated values in the provided text message
*
* `You phone number is {{context.vars.phone}}`
* Becomes
* `You phone number is 6354-543-534`
*
* @param text - Text message
* @param context - Object holding context variables relative to the conversation (temporary)
* @param subscriberContext - Object holding context values relative to the subscriber (permanent)
* @param settings - Settings Object
*
* @returns Text message with the tokens being replaced
*/
static compileHandlerbarsTemplate(
text: string,
context: Context,
settings: Settings,
): string {
// Build the template context for Handlebars to match our token paths
const templateContext: TemplateContext = {
context: { ...context },
contact: { ...settings.contact },
};
// Compile and run the Handlebars template
const compileTemplate = Handlebars.compile(
EnvelopeFactory.toHandlebars(text),
);
return compileTemplate(templateContext);
}
/**
* Processes the provided text or array of texts, localizes it based on the user's language settings,
* and then compiles it with the current context and settings using Handlebars templates.
*
* @param text - The text or an array of text strings to be processed.
* @returns - The processed and localized text.
*/
public processText(text: string | string[]): string {
let result = Array.isArray(text) ? getRandomElement(text) : text;
result = this.i18n.t(result, {
lang: this.context.user.language,
defaultValue: result,
});
result = EnvelopeFactory.compileHandlerbarsTemplate(
result,
this.context,
this.settings,
);
return result;
}
/**
* Returns an envelope builder instance for the specified message format.
*
* @template F - The envelope message format extending OutgoingMessageFormat.
* @param format - The desired envelope message format.
* @returns A builder instance for creating envelopes of the specified format.
*/
getBuilder<F extends OutgoingMessageFormat>(format: F) {
return getEnvelopeBuilder<F>(format);
}
/**
* Builds a text envelope by processing the provided text.
*
* This method processes the input text for localization and template compilation,
* then builds a text envelope using the envelope builder.
*
* @param text - The text content or an array of text variants.
* @returns A finalized text envelope object.
*/
buildTextEnvelope(text: string | string[]): StdOutgoingTextEnvelope {
const builder = this.getBuilder(OutgoingMessageFormat.text);
const processedText = this.processText(text);
return builder.text(processedText).build();
}
/**
* Builds a quick replies envelope by processing the text and quick reply items.
*
* Processes the input text for localization and template compilation, then appends each
* processed quick reply (with localized title and payload) to the envelope before finalizing it.
*
* @param text - The text content or an array of text variants.
* @param quickReplies - An array of quick reply objects.
* @returns A finalized quick replies envelope object.
*/
buildQuickRepliesEnvelope(
text: string | string[],
quickReplies: StdQuickReply[],
): StdOutgoingQuickRepliesEnvelope {
const builder = this.getBuilder(OutgoingMessageFormat.quickReplies);
const processedText = this.processText(text);
const envelope = builder.text(processedText);
quickReplies.forEach((qr) => {
envelope.appendToQuickReplies({
...qr,
title: this.processText(qr.title),
payload: this.processText(qr.payload),
});
});
return envelope.build();
}
/**
* Builds a buttons envelope by processing the text and button items.
*
* Processes the input text and iterates over the provided buttons.
* For postback buttons, both the title and payload are processed; for other button types,
* only the title is processed. Each processed button is then appended to the envelope.
*
* @param text - The text content or an array of text variants.
* @param buttons - An array of button objects.
* @returns A finalized buttons envelope object.
*/
buildButtonsEnvelope(
text: string | string[],
buttons: Button[],
): StdOutgoingButtonsEnvelope {
const builder = this.getBuilder(OutgoingMessageFormat.buttons);
const processedText = this.processText(text);
const envelope = builder.text(processedText);
buttons.forEach((btn) => {
if (btn.type === ButtonType.postback) {
envelope.appendToButtons({
...btn,
title: this.processText(btn.title),
payload: this.processText(btn.payload),
});
} else {
envelope.appendToButtons({
...btn,
title: this.processText(btn.title),
});
}
});
return envelope.build();
}
/**
* Builds an attachment envelope with the provided attachment payload.
*
* Sets the attachment on the envelope and appends any quick replies after processing
* them for localization and template compilation.
*
* @param attachment - The attachment payload object.
* @param quickReplies - Optional array of quick reply objects.
* @returns A finalized attachment envelope object.
*/
buildAttachmentEnvelope(
attachment: AttachmentPayload,
quickReplies: StdQuickReply[] = [],
): StdOutgoingAttachmentEnvelope {
const builder = this.getBuilder(OutgoingMessageFormat.attachment);
const envelope = builder.attachment(attachment);
quickReplies.forEach((qr) => {
envelope.appendToQuickReplies({
...qr,
title: this.processText(qr.title),
payload: this.processText(qr.payload),
});
});
return envelope.build();
}
/**
* Builds a list or carousel envelope using the provided content options.
*
* This method builds a list envelope (applicable for both carousel and list formats)
* by setting options, elements, and pagination details on the envelope.
*
* @param format - The envelope format (carousel or list).
* @param options - Options for content presentation.
* @param elements - An array of content elements.
* @param pagination - Pagination details for the content.
* @returns A finalized list envelope object.
*/
buildListEnvelope(
format: OutgoingMessageFormat.carousel | OutgoingMessageFormat.list,
options: ContentOptions,
elements: ContentElement[],
pagination: ContentPagination,
): StdOutgoingListEnvelope {
const builder = this.getBuilder(format);
return builder
.options(options)
.elements(elements)
.pagination(pagination)
.build();
}
/**
* Builds a system envelope with the specified outcome and optional data.
*
* Processes the provided outcome and additional data (if any) to create a system envelope.
* This envelope type is used for system-level messaging.
*
* @param outcome - The outcome message or status.
* @param data - Optional additional data to include in the envelope.
* @returns A finalized system envelope object.
*/
buildSystemEnvelope(
outcome: string | undefined,
data?: unknown,
): StdOutgoingSystemEnvelope {
const builder = this.getBuilder(OutgoingMessageFormat.system);
return builder.outcome(outcome).data(data).build();
}
}

View File

@ -117,14 +117,18 @@ export const contentElementSchema = z
export type ContentElement = z.infer<typeof contentElementSchema>;
export const contentPaginationSchema = z.object({
total: z.number(),
skip: z.number(),
limit: z.number(),
});
export type ContentPagination = z.infer<typeof contentPaginationSchema>;
export const stdOutgoingListMessageSchema = z.object({
options: contentOptionsSchema,
elements: z.array(contentElementSchema),
pagination: z.object({
total: z.number(),
skip: z.number(),
limit: z.number(),
}),
pagination: contentPaginationSchema,
});
export type StdOutgoingListMessage = z.infer<

View File

@ -49,8 +49,6 @@ import {
} from '@/utils/test/mocks/block';
import {
contextBlankInstance,
contextEmailVarInstance,
contextGetStartedInstance,
subscriberContextBlankInstance,
} from '@/utils/test/mocks/conversation';
import { nlpEntitiesGreeting } from '@/utils/test/mocks/nlp';
@ -64,9 +62,7 @@ import { Block, BlockModel } from '../schemas/block.schema';
import { Category, CategoryModel } from '../schemas/category.schema';
import { LabelModel } from '../schemas/label.schema';
import { FileType } from '../schemas/types/attachment';
import { Context } from '../schemas/types/context';
import { StdOutgoingListMessage } from '../schemas/types/message';
import { SubscriberContext } from '../schemas/types/subscriberContext';
import { CategoryRepository } from './../repositories/category.repository';
import { BlockService } from './block.service';
@ -81,8 +77,6 @@ describe('BlockService', () => {
let hasPreviousBlocks: Block;
let contentService: ContentService;
let contentTypeService: ContentTypeService;
let settingService: SettingService;
let settings: Settings;
beforeAll(async () => {
const module = await Test.createTestingModule({
@ -151,7 +145,6 @@ describe('BlockService', () => {
}).compile();
blockService = module.get<BlockService>(BlockService);
contentService = module.get<ContentService>(ContentService);
settingService = module.get<SettingService>(SettingService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
blockRepository = module.get<BlockRepository>(BlockRepository);
@ -160,7 +153,6 @@ describe('BlockService', () => {
name: 'hasPreviousBlocks',
}))!;
block = (await blockRepository.findOne({ name: 'hasNextBlocks' }))!;
settings = await settingService.getSettings();
});
afterEach(jest.clearAllMocks);
@ -208,25 +200,6 @@ describe('BlockService', () => {
});
});
describe('getRandom', () => {
it('should get a random message', () => {
const messages = [
'Hello, this is Nour',
'Oh ! How are you ?',
"Hmmm that's cool !",
'Corona virus',
'God bless you',
];
const result = blockService.getRandom(messages);
expect(messages).toContain(result);
});
it('should return undefined when trying to get a random message from an empty array', () => {
const result = blockService.getRandom([]);
expect(result).toBe(undefined);
});
});
describe('match', () => {
const handlerMock = {
getName: jest.fn(() => WEB_CHANNEL_NAME),
@ -511,222 +484,4 @@ describe('BlockService', () => {
});
});
});
describe('processText', () => {
const context: Context = {
...contextGetStartedInstance,
channel: 'web-channel',
text: '',
payload: undefined,
nlp: { entities: [] },
vars: { age: 21, email: 'email@example.com' },
user_location: {
address: { address: 'sangafora' },
lat: 23,
lon: 16,
},
user: subscriberWithoutLabels,
skip: { '1': 0 },
attempt: 0,
};
const subscriberContext: SubscriberContext = {
...subscriberContextBlankInstance,
vars: {
phone: '123456789',
},
};
it('should process empty text', () => {
const result = blockService.processText(
'',
context,
subscriberContext,
settings,
);
expect(result).toEqual('');
});
it('should process text translation', () => {
const translation = { en: 'Welcome', fr: 'Bienvenue' };
const result = blockService.processText(
translation.en,
context,
subscriberContext,
settings,
);
expect(result).toEqual(translation.fr);
});
it('should process text replacements with ontext vars', () => {
const result = blockService.processText(
'{{context.user.first_name}} {{context.user.last_name}}, email : {{context.vars.email}}',
contextEmailVarInstance,
subscriberContext,
settings,
);
expect(result).toEqual('John Doe, email : email@example.com');
});
it('should process text replacements with context vars', () => {
const result = blockService.processText(
'{{context.user.first_name}} {{context.user.last_name}}, phone : {{context.vars.phone}}',
contextEmailVarInstance,
subscriberContext,
settings,
);
expect(result).toEqual('John Doe, phone : 123456789');
});
it('should process text replacements with settings contact infos', () => {
const result = blockService.processText(
'Trying the settings : the name of company is <<{{contact.company_name}}>>',
contextBlankInstance,
subscriberContext,
settings,
);
expect(result).toEqual(
'Trying the settings : the name of company is <<Your company name>>',
);
});
});
describe('toHandlebars (private)', () => {
it('should convert single curly braces to double curly braces when no existing {{ }} are present', () => {
const input =
'Hello {context.user.name}, your phone is {context.vars.phone}';
// Access the private method using bracket notation
const result = blockService['toHandlebars'](input);
expect(result).toBe(
'Hello {{context.user.name}}, your phone is {{context.vars.phone}}',
);
});
it('should leave strings that already contain double curly braces unchanged', () => {
const input =
'Hello {{context.user.name}}, your phone is {{context.vars.phone}}';
const result = blockService['toHandlebars'](input);
expect(result).toBe(input);
});
it('should handle strings with no braces at all', () => {
const input = 'Hello world, no braces here';
const result = blockService['toHandlebars'](input);
// Should be unchanged since there are no placeholders
expect(result).toBe(input);
});
it('should handle multiple single placeholders correctly', () => {
const input = '{one} {two} {three}';
const result = blockService['toHandlebars'](input);
expect(result).toBe('{{one}} {{two}} {{three}}');
});
});
describe('processTokenReplacements', () => {
it('should replace tokens with context variables correctly', () => {
const text =
'Hello {context.user.name}, your phone is {context.vars.phone}';
const context = {
user: { name: 'John Doe' },
vars: { phone: '123-456-7890' },
} as unknown as Context;
const subscriberContext = {
// This can hold overlapping or additional vars
vars: {
otherVar: 'Some Value',
},
} as unknown as SubscriberContext;
const settings = {
contact: {
email: 'contact@example.com',
},
} as unknown as Settings;
const result = blockService.processTokenReplacements(
text,
context,
subscriberContext,
settings,
);
// Expect that single curly braces got turned into Handlebars placeholders
// and then replaced with actual values from the merged context
expect(result).toBe('Hello John Doe, your phone is 123-456-7890');
});
it('should merge subscriberContext.vars and context.vars correctly', () => {
const text =
'Subscriber var: {context.vars.subscriberVar}, Context var: {context.vars.contextVar}';
const context = {
user: {},
vars: {
contextVar: 'ContextValue',
},
} as unknown as Context;
const subscriberContext = {
vars: {
subscriberVar: 'SubscriberValue',
},
} as unknown as SubscriberContext;
const settings = {
contact: {},
} as unknown as Settings;
const result = blockService.processTokenReplacements(
text,
context,
subscriberContext,
settings,
);
expect(result).toBe(
'Subscriber var: SubscriberValue, Context var: ContextValue',
);
});
it('should use contact from settings if provided', () => {
const text = 'You can reach us at {{contact.email}}';
const context = {
user: { name: 'Alice' },
vars: {},
} as unknown as Context;
const subscriberContext = {
vars: {},
} as unknown as SubscriberContext;
const settings = {
contact: {
email: 'support@example.com',
},
} as unknown as Settings;
const result = blockService.processTokenReplacements(
text,
context,
subscriberContext,
settings,
);
expect(result).toBe('You can reach us at support@example.com');
});
it('should handle no placeholders gracefully', () => {
const text = 'No placeholders here.';
const context = {
user: {},
vars: {},
} as unknown as Context;
const subscriberContext = {
vars: {},
} as unknown as SubscriberContext;
const settings = {
contact: {},
} as unknown as Settings;
const result = blockService.processTokenReplacements(
text,
context,
subscriberContext,
settings,
);
expect(result).toBe('No placeholders here.');
});
});
});

View File

@ -8,9 +8,7 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import Handlebars from 'handlebars';
import { AttachmentService } from '@/attachment/services/attachment.service';
import EventWrapper from '@/channel/lib/EventWrapper';
import { ChannelName } from '@/channel/types';
import { ContentService } from '@/cms/services/content.service';
@ -23,14 +21,15 @@ import { PluginService } from '@/plugins/plugins.service';
import { PluginType } from '@/plugins/types';
import { SettingService } from '@/setting/services/setting.service';
import { BaseService } from '@/utils/generics/base-service';
import { getRandom } from '@/utils/helpers/safeRandom';
import { getRandomElement } from '@/utils/helpers/safeRandom';
import { BlockDto } from '../dto/block.dto';
import { EnvelopeFactory } from '../helpers/envelope-factory';
import { BlockRepository } from '../repositories/block.repository';
import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema';
import { Label } from '../schemas/label.schema';
import { Subscriber } from '../schemas/subscriber.schema';
import { Context, TemplateContext } from '../schemas/types/context';
import { Context } from '../schemas/types/context';
import {
BlockMessage,
OutgoingMessageFormat,
@ -51,7 +50,6 @@ export class BlockService extends BaseService<
constructor(
readonly repository: BlockRepository,
private readonly contentService: ContentService,
private readonly attachmentService: AttachmentService,
private readonly settingService: SettingService,
private readonly pluginService: PluginService,
private readonly logger: LoggerService,
@ -372,23 +370,6 @@ export class BlockService extends BaseService<
});
}
/**
* Converts an old text template with single-curly placeholders, e.g. `{context.user.name}`,
* into a Handlebars-style template, e.g. `{{context.user.name}}`.
*
* @param str - The template string you want to convert.
* @returns The converted template string with Handlebars-style placeholders.
*/
private toHandlebars(str: string) {
// If the string already contains {{ }}, assume it's already a handlebars template.
if (/\{\{.*\}\}/.test(str)) {
return str;
}
// Otherwise, replace single curly braces { } with double curly braces {{ }}.
return str.replaceAll(/{([^}]+)}/g, '{{$1}}');
}
/**
* Replaces tokens with their context variables values in the provided text message
*
@ -409,26 +390,23 @@ export class BlockService extends BaseService<
subscriberContext: SubscriberContext,
settings: Settings,
): string {
// Build the template context for Handlebars to match our token paths
const templateContext: TemplateContext = {
context: {
return EnvelopeFactory.compileHandlerbarsTemplate(
text,
{
...context,
vars: {
...(subscriberContext?.vars || {}),
...(context.vars || {}),
},
},
contact: { ...settings.contact },
};
// Compile and run the Handlebars template
const compileTemplate = Handlebars.compile(this.toHandlebars(text));
return compileTemplate(templateContext);
settings,
);
}
/**
* Translates and replaces tokens with context variables values
*
* @deprecated use EnvelopeFactory.processText() instead
* @param text - Text to process
* @param context - The context object
*
@ -440,32 +418,31 @@ export class BlockService extends BaseService<
subscriberContext: SubscriberContext,
settings: Settings,
): string {
// Translate
text = this.i18n.t(text, {
lang: context.user.language,
defaultValue: text,
});
// Replace context tokens
text = this.processTokenReplacements(
text,
context,
subscriberContext,
const envelopeFactory = new EnvelopeFactory(
{
...context,
vars: {
...context.vars,
...subscriberContext.vars,
},
},
settings,
this.i18n,
);
return text;
return envelopeFactory.processText(text);
}
/**
* Return a randomly picked item of the array
*
* @deprecated use helper getRandomElement() instead
* @param array - Array of any type
*
* @returns A random item from the array
*/
getRandom<T>(array: T[]): T {
return Array.isArray(array)
? array[Math.floor(getRandom() * array.length)]
: array;
return getRandomElement(array);
}
/**
@ -515,7 +492,7 @@ export class BlockService extends BaseService<
// Text Message
// Get random message from array
const text = this.processText(
this.getRandom(blockMessage),
getRandomElement(blockMessage),
context,
subscriberContext,
settings,

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* 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.
@ -18,6 +18,7 @@ import { BlockModel } from '@/chat/schemas/block.schema';
import { CmsModule } from '@/cms/cms.module';
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
import { ContentModel } from '@/cms/schemas/content.schema';
import { NlpModule } from '@/nlp/nlp.module';
import { PluginService } from './plugins.service';
@ -42,6 +43,7 @@ import { PluginService } from './plugins.service';
AttachmentModule,
ChatModule,
HttpModule,
NlpModule,
],
providers: [PluginService],
exports: [PluginService],

View File

@ -0,0 +1,30 @@
/*
* 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 { getRandomElement } from './safeRandom';
describe('safeRandom', () => {
describe('getRandomElement', () => {
it('should get a random message', () => {
const messages = [
'Hello, this is Nour',
'Oh ! How are you ?',
"Hmmm that's cool !",
'Corona virus',
'God bless you',
];
const result = getRandomElement(messages);
expect(messages).toContain(result);
});
it('should return undefined when trying to get a random message from an empty array', () => {
const result = getRandomElement([]);
expect(result).toBe(undefined);
});
});
});

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* 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.
@ -15,3 +15,16 @@ import crypto from 'crypto';
*/
export const getRandom = (): number =>
crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32;
/**
* Return a randomly picked item of the array
*
* @param array - Array of any type
*
* @returns A random item from the array
*/
export const getRandomElement = <T>(array: T[]): T => {
return Array.isArray(array)
? array[Math.floor(getRandom() * array.length)]
: array;
};