mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +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 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({
|
export const stdOutgoingListMessageSchema = z.object({
|
||||||
options: contentOptionsSchema,
|
options: contentOptionsSchema,
|
||||||
elements: z.array(contentElementSchema),
|
elements: z.array(contentElementSchema),
|
||||||
pagination: z.object({
|
pagination: contentPaginationSchema,
|
||||||
total: z.number(),
|
|
||||||
skip: z.number(),
|
|
||||||
limit: z.number(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type StdOutgoingListMessage = z.infer<
|
export type StdOutgoingListMessage = z.infer<
|
||||||
|
|||||||
@ -49,8 +49,6 @@ import {
|
|||||||
} from '@/utils/test/mocks/block';
|
} from '@/utils/test/mocks/block';
|
||||||
import {
|
import {
|
||||||
contextBlankInstance,
|
contextBlankInstance,
|
||||||
contextEmailVarInstance,
|
|
||||||
contextGetStartedInstance,
|
|
||||||
subscriberContextBlankInstance,
|
subscriberContextBlankInstance,
|
||||||
} from '@/utils/test/mocks/conversation';
|
} from '@/utils/test/mocks/conversation';
|
||||||
import { nlpEntitiesGreeting } from '@/utils/test/mocks/nlp';
|
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 { Category, CategoryModel } from '../schemas/category.schema';
|
||||||
import { LabelModel } from '../schemas/label.schema';
|
import { LabelModel } from '../schemas/label.schema';
|
||||||
import { FileType } from '../schemas/types/attachment';
|
import { FileType } from '../schemas/types/attachment';
|
||||||
import { Context } from '../schemas/types/context';
|
|
||||||
import { StdOutgoingListMessage } from '../schemas/types/message';
|
import { StdOutgoingListMessage } from '../schemas/types/message';
|
||||||
import { SubscriberContext } from '../schemas/types/subscriberContext';
|
|
||||||
|
|
||||||
import { CategoryRepository } from './../repositories/category.repository';
|
import { CategoryRepository } from './../repositories/category.repository';
|
||||||
import { BlockService } from './block.service';
|
import { BlockService } from './block.service';
|
||||||
@ -81,8 +77,6 @@ describe('BlockService', () => {
|
|||||||
let hasPreviousBlocks: Block;
|
let hasPreviousBlocks: Block;
|
||||||
let contentService: ContentService;
|
let contentService: ContentService;
|
||||||
let contentTypeService: ContentTypeService;
|
let contentTypeService: ContentTypeService;
|
||||||
let settingService: SettingService;
|
|
||||||
let settings: Settings;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
@ -151,7 +145,6 @@ describe('BlockService', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
blockService = module.get<BlockService>(BlockService);
|
blockService = module.get<BlockService>(BlockService);
|
||||||
contentService = module.get<ContentService>(ContentService);
|
contentService = module.get<ContentService>(ContentService);
|
||||||
settingService = module.get<SettingService>(SettingService);
|
|
||||||
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
|
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
|
||||||
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
|
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
|
||||||
blockRepository = module.get<BlockRepository>(BlockRepository);
|
blockRepository = module.get<BlockRepository>(BlockRepository);
|
||||||
@ -160,7 +153,6 @@ describe('BlockService', () => {
|
|||||||
name: 'hasPreviousBlocks',
|
name: 'hasPreviousBlocks',
|
||||||
}))!;
|
}))!;
|
||||||
block = (await blockRepository.findOne({ name: 'hasNextBlocks' }))!;
|
block = (await blockRepository.findOne({ name: 'hasNextBlocks' }))!;
|
||||||
settings = await settingService.getSettings();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(jest.clearAllMocks);
|
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', () => {
|
describe('match', () => {
|
||||||
const handlerMock = {
|
const handlerMock = {
|
||||||
getName: jest.fn(() => WEB_CHANNEL_NAME),
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import Handlebars from 'handlebars';
|
|
||||||
|
|
||||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
|
||||||
import EventWrapper from '@/channel/lib/EventWrapper';
|
import EventWrapper from '@/channel/lib/EventWrapper';
|
||||||
import { ChannelName } from '@/channel/types';
|
import { ChannelName } from '@/channel/types';
|
||||||
import { ContentService } from '@/cms/services/content.service';
|
import { ContentService } from '@/cms/services/content.service';
|
||||||
@ -23,14 +21,15 @@ import { PluginService } from '@/plugins/plugins.service';
|
|||||||
import { PluginType } from '@/plugins/types';
|
import { PluginType } from '@/plugins/types';
|
||||||
import { SettingService } from '@/setting/services/setting.service';
|
import { SettingService } from '@/setting/services/setting.service';
|
||||||
import { BaseService } from '@/utils/generics/base-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 { BlockDto } from '../dto/block.dto';
|
||||||
|
import { EnvelopeFactory } from '../helpers/envelope-factory';
|
||||||
import { BlockRepository } from '../repositories/block.repository';
|
import { BlockRepository } from '../repositories/block.repository';
|
||||||
import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema';
|
import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema';
|
||||||
import { Label } from '../schemas/label.schema';
|
import { Label } from '../schemas/label.schema';
|
||||||
import { Subscriber } from '../schemas/subscriber.schema';
|
import { Subscriber } from '../schemas/subscriber.schema';
|
||||||
import { Context, TemplateContext } from '../schemas/types/context';
|
import { Context } from '../schemas/types/context';
|
||||||
import {
|
import {
|
||||||
BlockMessage,
|
BlockMessage,
|
||||||
OutgoingMessageFormat,
|
OutgoingMessageFormat,
|
||||||
@ -51,7 +50,6 @@ export class BlockService extends BaseService<
|
|||||||
constructor(
|
constructor(
|
||||||
readonly repository: BlockRepository,
|
readonly repository: BlockRepository,
|
||||||
private readonly contentService: ContentService,
|
private readonly contentService: ContentService,
|
||||||
private readonly attachmentService: AttachmentService,
|
|
||||||
private readonly settingService: SettingService,
|
private readonly settingService: SettingService,
|
||||||
private readonly pluginService: PluginService,
|
private readonly pluginService: PluginService,
|
||||||
private readonly logger: LoggerService,
|
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
|
* Replaces tokens with their context variables values in the provided text message
|
||||||
*
|
*
|
||||||
@ -409,26 +390,23 @@ export class BlockService extends BaseService<
|
|||||||
subscriberContext: SubscriberContext,
|
subscriberContext: SubscriberContext,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
): string {
|
): string {
|
||||||
// Build the template context for Handlebars to match our token paths
|
return EnvelopeFactory.compileHandlerbarsTemplate(
|
||||||
const templateContext: TemplateContext = {
|
text,
|
||||||
context: {
|
{
|
||||||
...context,
|
...context,
|
||||||
vars: {
|
vars: {
|
||||||
...(subscriberContext?.vars || {}),
|
...(subscriberContext?.vars || {}),
|
||||||
...(context.vars || {}),
|
...(context.vars || {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
contact: { ...settings.contact },
|
settings,
|
||||||
};
|
);
|
||||||
|
|
||||||
// Compile and run the Handlebars template
|
|
||||||
const compileTemplate = Handlebars.compile(this.toHandlebars(text));
|
|
||||||
return compileTemplate(templateContext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates and replaces tokens with context variables values
|
* Translates and replaces tokens with context variables values
|
||||||
*
|
*
|
||||||
|
* @deprecated use EnvelopeFactory.processText() instead
|
||||||
* @param text - Text to process
|
* @param text - Text to process
|
||||||
* @param context - The context object
|
* @param context - The context object
|
||||||
*
|
*
|
||||||
@ -440,32 +418,31 @@ export class BlockService extends BaseService<
|
|||||||
subscriberContext: SubscriberContext,
|
subscriberContext: SubscriberContext,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
): string {
|
): string {
|
||||||
// Translate
|
const envelopeFactory = new EnvelopeFactory(
|
||||||
text = this.i18n.t(text, {
|
{
|
||||||
lang: context.user.language,
|
...context,
|
||||||
defaultValue: text,
|
vars: {
|
||||||
});
|
...context.vars,
|
||||||
// Replace context tokens
|
...subscriberContext.vars,
|
||||||
text = this.processTokenReplacements(
|
},
|
||||||
text,
|
},
|
||||||
context,
|
|
||||||
subscriberContext,
|
|
||||||
settings,
|
settings,
|
||||||
|
this.i18n,
|
||||||
);
|
);
|
||||||
return text;
|
|
||||||
|
return envelopeFactory.processText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a randomly picked item of the array
|
* Return a randomly picked item of the array
|
||||||
*
|
*
|
||||||
|
* @deprecated use helper getRandomElement() instead
|
||||||
* @param array - Array of any type
|
* @param array - Array of any type
|
||||||
*
|
*
|
||||||
* @returns A random item from the array
|
* @returns A random item from the array
|
||||||
*/
|
*/
|
||||||
getRandom<T>(array: T[]): T {
|
getRandom<T>(array: T[]): T {
|
||||||
return Array.isArray(array)
|
return getRandomElement(array);
|
||||||
? array[Math.floor(getRandom() * array.length)]
|
|
||||||
: array;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -515,7 +492,7 @@ export class BlockService extends BaseService<
|
|||||||
// Text Message
|
// Text Message
|
||||||
// Get random message from array
|
// Get random message from array
|
||||||
const text = this.processText(
|
const text = this.processText(
|
||||||
this.getRandom(blockMessage),
|
getRandomElement(blockMessage),
|
||||||
context,
|
context,
|
||||||
subscriberContext,
|
subscriberContext,
|
||||||
settings,
|
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:
|
* 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.
|
* 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 { CmsModule } from '@/cms/cms.module';
|
||||||
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
|
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
|
||||||
import { ContentModel } from '@/cms/schemas/content.schema';
|
import { ContentModel } from '@/cms/schemas/content.schema';
|
||||||
|
import { NlpModule } from '@/nlp/nlp.module';
|
||||||
|
|
||||||
import { PluginService } from './plugins.service';
|
import { PluginService } from './plugins.service';
|
||||||
|
|
||||||
@ -42,6 +43,7 @@ import { PluginService } from './plugins.service';
|
|||||||
AttachmentModule,
|
AttachmentModule,
|
||||||
ChatModule,
|
ChatModule,
|
||||||
HttpModule,
|
HttpModule,
|
||||||
|
NlpModule,
|
||||||
],
|
],
|
||||||
providers: [PluginService],
|
providers: [PluginService],
|
||||||
exports: [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:
|
* 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.
|
* 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 =>
|
export const getRandom = (): number =>
|
||||||
crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32;
|
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