Merge pull request #853 from Hexastack/feat/envelope-factory
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: envelope factory
This commit is contained in:
Med Marrouchi 2025-03-24 13:36:03 +01:00 committed by GitHub
commit c91bf8c4f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1115 additions and 298 deletions

View File

@ -0,0 +1,115 @@
# Envelope Helpers : Envelope Builder & Envelope Factory
The envelope helpers introduce two key components to streamline outgoing message envelope creation: the **Envelope Builder** and the **Envelope Factory**. Together, they offer a comprehensive solution for constructing, localizing, and validating messages in various formats with minimal boilerplate.
---
## Overview
- **Envelope Builder:**
A Proxy-based builder utility that provides chainable setter methods to dynamically create envelope objects. It validates the final envelope using Zod schemas and supports dynamic array handling through methods like `appendToQuickReplies`.
- **Envelope Factory:**
A higher-level abstraction that builds on the Envelope Builder. It integrates localization and templating using Handlebars and provides convenience methods for constructing different types of envelopes (text, quick replies, buttons, attachments, lists/carousels, and system messages).
---
## Key Features
### Envelope Builder
- **Chainable Setter Methods:**
Methods dynamically set and retrieve message properties, making envelope configuration both flexible and intuitive.
- **Dynamic Array Handling:**
Provides special `appendToX` methods to easily add items to array fields (e.g., quick replies, buttons).
- **Schema Validation:**
Uses Zod to validate envelopes against predefined schemas, ensuring message integrity and compliance with expected formats.
- **Format-Specific Construction:**
Pre-configured through `getEnvelopeBuilder` to support various message formats (text, quick replies, buttons, attachment, carousel, list, and system).
### Envelope Factory
- **Template Conversion & Compilation:**
- **toHandlebars:** Converts legacy single-curly brace templates (e.g., `{context.user.name}`) into Handlebars-style (`{{context.user.name}}`).
- **compileHandlebarsTemplate:** Compiles and processes these templates by injecting contextual data, allowing dynamic content generation.
- **Localization:**
Processes input text for localization using an integrated i18n service, ensuring that messages are tailored to the user's language settings.
- **Convenience Envelope Methods:**
Provides methods to build various envelope types:
- **buildTextEnvelope:** Processes and builds text envelopes.
- **buildQuickRepliesEnvelope:** Constructs quick reply messages with localized titles and payloads.
- **buildButtonsEnvelope:** Handles both postback and non-postback buttons with proper text processing.
- **buildAttachmentEnvelope:** Creates attachment envelopes with optional quick replies.
- **buildListEnvelope:** Builds list/carousel envelopes using provided content options, elements, and pagination.
- **buildSystemEnvelope:** Assembles system envelopes with outcomes and optional data.
- **Integration with Envelope Builder:**
Utilizes the Envelope Builder internally to ensure type-safe envelope construction and validation.
---
## Usage Examples
### Using Envelope Builder Directly
```typescript
// Build a simple text envelope:
const env1 = EnvelopeBuilder(OutgoingMessageFormat.text)
.setText('Hello')
.build();
// Append multiple quick replies:
const env2 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies)
.setText('Are you interested?')
.appendToQuickReplies({
content_type: QuickReplyType.text,
title: 'Yes',
payload: 'yes',
})
.appendToQuickReplies({
content_type: QuickReplyType.text,
title: 'No',
payload: 'no',
})
.build();
```
### Using Envelope Factory
```typescript
const envelopeFactory = new EnvelopeFactory(context, settings, i18nService);
// Build a localized text envelope:
const textEnvelope = envelopeFactory.buildTextEnvelope('Hello, {context.user.name}!');
// Build a quick replies envelope with processed quick replies:
const quickRepliesEnvelope = envelopeFactory.buildQuickRepliesEnvelope(
'Do you want to proceed?',
[
{ content_type: QuickReplyType.text, title: 'Yes', payload: 'yes' },
{ content_type: QuickReplyType.text, title: 'No', payload: 'no' }
]
);
```
---
## Implementation Details
- **Proxy-based Dynamic Methods:**
The Envelope Builder leverages JavaScript Proxies to intercept property accesses, enabling both setter and getter behaviors, as well as dynamic array appending for enhanced flexibility.
- **Type Safety with Generics:**
Both components use TypeScript generics and strict typing to ensure that each envelope adheres to its specific format, reducing runtime errors and enforcing schema compliance.
- **Localization & Templating:**
The Envelope Factory integrates with an i18n service and uses Handlebars to convert and compile message templates, ensuring that all messages are correctly localized and dynamically composed with context-specific data.
- **Schema Mapping:**
A mapping between message formats and their corresponding Zod schemas guarantees that envelopes are built and validated against the correct structure.

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.setText('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.setText('Hello world');
// Retrieve current value with no argument
expect(builder.getText()).toBe('Hello world');
});
it('should append items to array fields with appendToX methods', () => {
const builder = EnvelopeBuilder<StdOutgoingQuickRepliesEnvelope>(
OutgoingMessageFormat.quickReplies,
{},
stdOutgoingQuickRepliesEnvelopeSchema,
);
builder.setText('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: QuickReplyType.text, title: 'Yes', payload: 'yes' },
{ content_type: QuickReplyType.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.setText('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.setText('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,209 @@
/*
* 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'] as `set${Capitalize<string & K>}`]-?: (
arg: T['message'][K],
) => IEnvelopeBuilder<T>;
} & {
[K in keyof T['message'] as `get${Capitalize<string & K>}`]-?: () => 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;
};
/**
* Extracts and transforms a property name into a standardized attribute name.
*
* @param prop - The property name from which to derive the attribute name.
* @param prefix - A regular expression that matches the prefix to remove from the property.
* @returns The transformed attribute name with its first character in lowercase.
*/
function getAttributeNameFromProp(prop: string, prefix: RegExp) {
// e.g. "appendToButtons" => "Buttons"
const rawKey = prop.toString().replace(prefix, '');
// e.g. "Buttons" -> "buttons"
const messageKey = rawKey.charAt(0).toLowerCase() + rawKey.slice(1);
return messageKey;
}
/**
* 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)
* .setText('Hello')
* .build();
*
* @example
* // Build a text envelope with quick replies:
* const env2 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies)
* .setText('Hello')
* .setQuickReplies([])
* .build();
*
* @example
* // Append multiple quickReplies items:
* const env3 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies)
* .setText('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)
* .setOutcome('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')) {
const messageKey = getAttributeNameFromProp(prop, /^appendTo/);
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) {
const messageKey = getAttributeNameFromProp(
prop.toString(),
/^get/,
);
return built.message[messageKey];
}
const value = args[0];
const messageKey = getAttributeNameFromProp(prop.toString(), /^set/);
built.message[messageKey] = 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 { QuickReplyType, 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('compileHandlebarsTemplate', () => {
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.compileHandlebarsTemplate(
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.compileHandlebarsTemplate(
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.compileHandlebarsTemplate(
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.compileHandlebarsTemplate(
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: QuickReplyType.text,
title: 'Yes {{contact.company_name}}',
payload: 'do_{{context.user.id}}',
},
{
content_type: QuickReplyType.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: QuickReplyType.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,273 @@
/*
* 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 Handlebars from 'handlebars';
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 compileHandlebarsTemplate(
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.compileHandlebarsTemplate(
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.setText(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.setText(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.setText(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.setAttachment(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
.setOptions(options)
.setElements(elements)
.setPagination(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.setOutcome(outcome).setData(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.compileHandlebarsTemplate(
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;
};