mirror of
https://github.com/hexastack/hexabot
synced 2025-06-04 03:26:22 +00:00
feat: envelope factory
This commit is contained in:
parent
e0a77302cc
commit
10b39529b9
121
api/src/chat/helpers/envelope-builder.spec.ts
Normal file
121
api/src/chat/helpers/envelope-builder.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
190
api/src/chat/helpers/envelope-builder.ts
Normal file
190
api/src/chat/helpers/envelope-builder.ts
Normal 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],
|
||||
);
|
||||
};
|
318
api/src/chat/helpers/envelope-factory.spec.ts
Normal file
318
api/src/chat/helpers/envelope-factory.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
271
api/src/chat/helpers/envelope-factory.ts
Normal file
271
api/src/chat/helpers/envelope-factory.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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<
|
||||
|
@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
|
30
api/src/utils/helpers/safeRandom.spec.ts
Normal file
30
api/src/utils/helpers/safeRandom.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user