feat: add handlebars template for text

This commit is contained in:
Mohamed Marrouchi 2025-03-20 20:31:04 +01:00
parent e75d00f9f5
commit e90a268b84
6 changed files with 195 additions and 69 deletions

10
api/package-lock.json generated
View File

@ -35,6 +35,7 @@
"dotenv": "^16.3.1",
"ejs": "^3.1.9",
"express-session": "^1.17.3",
"handlebars": "^4.7.8",
"module-alias": "^2.2.3",
"mongoose": "^8.0.0",
"mongoose-lean-defaults": "^2.2.1",
@ -11461,7 +11462,6 @@
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"devOptional": true,
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
@ -11482,7 +11482,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"devOptional": true,
"engines": {
"node": ">=0.10.0"
}
@ -15296,8 +15295,7 @@
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"devOptional": true
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"node_modules/nest-commander": {
"version": "3.15.0",
@ -19313,7 +19311,6 @@
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
"dev": true,
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
@ -20048,8 +20045,7 @@
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"devOptional": true
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
},
"node_modules/wrap-ansi": {
"version": "6.2.0",

View File

@ -70,6 +70,7 @@
"dotenv": "^16.3.1",
"ejs": "^3.1.9",
"express-session": "^1.17.3",
"handlebars": "^4.7.8",
"module-alias": "^2.2.3",
"mongoose": "^8.0.0",
"mongoose-lean-defaults": "^2.2.1",

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -28,3 +28,8 @@ export interface Context {
skip: Record<string, number>;
attempt: number;
}
export interface TemplateContext {
context: Context;
contact: Settings['contact'];
}

View File

@ -559,7 +559,7 @@ describe('BlockService', () => {
it('should process text replacements with ontext vars', () => {
const result = blockService.processText(
'{context.user.first_name} {context.user.last_name}, email : {context.vars.email}',
'{{context.user.first_name}} {{context.user.last_name}}, email : {{context.vars.email}}',
contextEmailVarInstance,
subscriberContext,
settings,
@ -569,7 +569,7 @@ describe('BlockService', () => {
it('should process text replacements with context vars', () => {
const result = blockService.processText(
'{context.user.first_name} {context.user.last_name}, phone : {context.vars.phone}',
'{{context.user.first_name}} {{context.user.last_name}}, phone : {{context.vars.phone}}',
contextEmailVarInstance,
subscriberContext,
settings,
@ -579,7 +579,7 @@ describe('BlockService', () => {
it('should process text replacements with settings contact infos', () => {
const result = blockService.processText(
'Trying the settings : the name of company is <<{contact.company_name}>>',
'Trying the settings : the name of company is <<{{contact.company_name}}>>',
contextBlankInstance,
subscriberContext,
settings,
@ -589,4 +589,144 @@ describe('BlockService', () => {
);
});
});
describe('toHandlebars (private)', () => {
it('should convert single curly braces to double curly braces when no existing {{ }} are present', () => {
const input =
'Hello {context.user.name}, your phone is {context.vars.phone}';
// Access the private method using bracket notation
const result = blockService['toHandlebars'](input);
expect(result).toBe(
'Hello {{context.user.name}}, your phone is {{context.vars.phone}}',
);
});
it('should leave strings that already contain double curly braces unchanged', () => {
const input =
'Hello {{context.user.name}}, your phone is {{context.vars.phone}}';
const result = blockService['toHandlebars'](input);
expect(result).toBe(input);
});
it('should handle strings with no braces at all', () => {
const input = 'Hello world, no braces here';
const result = blockService['toHandlebars'](input);
// Should be unchanged since there are no placeholders
expect(result).toBe(input);
});
it('should handle multiple single placeholders correctly', () => {
const input = '{one} {two} {three}';
const result = blockService['toHandlebars'](input);
expect(result).toBe('{{one}} {{two}} {{three}}');
});
});
describe('processTokenReplacements', () => {
it('should replace tokens with context variables correctly', () => {
const text =
'Hello {context.user.name}, your phone is {context.vars.phone}';
const context = {
user: { name: 'John Doe' },
vars: { phone: '123-456-7890' },
} as unknown as Context;
const subscriberContext = {
// This can hold overlapping or additional vars
vars: {
otherVar: 'Some Value',
},
} as unknown as SubscriberContext;
const settings = {
contact: {
email: 'contact@example.com',
},
} as unknown as Settings;
const result = blockService.processTokenReplacements(
text,
context,
subscriberContext,
settings,
);
// Expect that single curly braces got turned into Handlebars placeholders
// and then replaced with actual values from the merged context
expect(result).toBe('Hello John Doe, your phone is 123-456-7890');
});
it('should merge subscriberContext.vars and context.vars correctly', () => {
const text =
'Subscriber var: {context.vars.subscriberVar}, Context var: {context.vars.contextVar}';
const context = {
user: {},
vars: {
contextVar: 'ContextValue',
},
} as unknown as Context;
const subscriberContext = {
vars: {
subscriberVar: 'SubscriberValue',
},
} as unknown as SubscriberContext;
const settings = {
contact: {},
} as unknown as Settings;
const result = blockService.processTokenReplacements(
text,
context,
subscriberContext,
settings,
);
expect(result).toBe(
'Subscriber var: SubscriberValue, Context var: ContextValue',
);
});
it('should use contact from settings if provided', () => {
const text = 'You can reach us at {{contact.email}}';
const context = {
user: { name: 'Alice' },
vars: {},
} as unknown as Context;
const subscriberContext = {
vars: {},
} as unknown as SubscriberContext;
const settings = {
contact: {
email: 'support@example.com',
},
} as unknown as Settings;
const result = blockService.processTokenReplacements(
text,
context,
subscriberContext,
settings,
);
expect(result).toBe('You can reach us at support@example.com');
});
it('should handle no placeholders gracefully', () => {
const text = 'No placeholders here.';
const context = {
user: {},
vars: {},
} as unknown as Context;
const subscriberContext = {
vars: {},
} as unknown as SubscriberContext;
const settings = {
contact: {},
} as unknown as Settings;
const result = blockService.processTokenReplacements(
text,
context,
subscriberContext,
settings,
);
expect(result).toBe('No placeholders here.');
});
});
});

View File

@ -8,6 +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';
@ -29,7 +30,7 @@ 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 } from '../schemas/types/context';
import { Context, TemplateContext } from '../schemas/types/context';
import {
BlockMessage,
OutgoingMessageFormat,
@ -371,15 +372,34 @@ 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
*
* `You phone number is {context.vars.phone}`
* `You phone number is {{context.vars.phone}}`
* Becomes
* `You phone number is 6354-543-534`
*
* @param text - Text message
* @param context - Variable holding context values relative to the subscriber
* @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
*/
@ -389,57 +409,21 @@ export class BlockService extends BaseService<
subscriberContext: SubscriberContext,
settings: Settings,
): string {
const vars = { ...(subscriberContext?.vars || {}), ...context.vars };
// Replace context tokens with their values
Object.keys(vars).forEach((key) => {
if (typeof vars[key] === 'string' && vars[key].indexOf(':') !== -1) {
const tmp = vars[key].split(':');
vars[key] = tmp[1];
}
text = text.replace(
'{context.vars.' + key + '}',
typeof vars[key] === 'string' ? vars[key] : JSON.stringify(vars[key]),
);
});
// Build the template context for Handlebars to match our token paths
const templateContext: TemplateContext = {
context: {
...context,
vars: {
...(subscriberContext?.vars || {}),
...(context.vars || {}),
},
},
contact: { ...settings.contact },
};
// Replace context tokens about user location
if (context.user_location) {
if (context.user_location.address) {
const userAddress = context.user_location.address;
Object.keys(userAddress).forEach((key) => {
text = text.replace(
'{context.user_location.address.' + key + '}',
typeof userAddress[key] === 'string'
? userAddress[key]
: JSON.stringify(userAddress[key]),
);
});
}
text = text.replace(
'{context.user_location.lat}',
context.user_location.lat.toString(),
);
text = text.replace(
'{context.user_location.lon}',
context.user_location.lon.toString(),
);
}
// Replace tokens for user infos
Object.keys(context.user).forEach((key) => {
const userAttr = (context.user as any)[key];
text = text.replace(
'{context.user.' + key + '}',
typeof userAttr === 'string' ? userAttr : JSON.stringify(userAttr),
);
});
// Replace contact infos tokens with their values
Object.keys(settings.contact).forEach((key) => {
text = text.replace('{contact.' + key + '}', settings.contact[key]);
});
return text;
// Compile and run the Handlebars template
const compileTemplate = Handlebars.compile(this.toHandlebars(text));
return compileTemplate(templateContext);
}
/**

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -65,7 +65,7 @@ function ReplacementTokens() {
{(contextVars || []).map((v, index) => (
<ListItem key={index}>
<ListItemText
primary={`{context.vars.${v.name}}`}
primary={`{{context.vars.${v.name}}}`}
secondary={`${v.label}`}
/>
</ListItem>
@ -76,7 +76,7 @@ function ReplacementTokens() {
{userInfos.map((v, index) => (
<ListItem key={index}>
<ListItemText
primary={`{context.user.${v.name}}`}
primary={`{{context.user.${v.name}}}`}
secondary={`${v.label}`}
/>
</ListItem>
@ -87,7 +87,7 @@ function ReplacementTokens() {
{userLocation.map((v, index) => (
<ListItem key={index}>
<ListItemText
primary={`{context.user_location.${v.name}}`}
primary={`{{context.user_location.${v.name}}}`}
secondary={`${v.label}`}
/>
</ListItem>
@ -100,7 +100,7 @@ function ReplacementTokens() {
{(contactInfos || []).map((v, index) => (
<ListItem key={index}>
<ListItemText
primary={`{contact.${v.label}}`}
primary={`{{contact.${v.label}}}`}
secondary={t(`label.${v.label}`, {
ns: "contact",
})}