From e90a268b84767f56b761f417c7b9d849961ce140 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 20 Mar 2025 20:31:04 +0100 Subject: [PATCH] feat: add handlebars template for text --- api/package-lock.json | 10 +- api/package.json | 1 + api/src/chat/schemas/types/context.ts | 7 +- api/src/chat/services/block.service.spec.ts | 146 +++++++++++++++++- api/src/chat/services/block.service.ts | 90 +++++------ .../form/inputs/ReplacementTokens.tsx | 10 +- 6 files changed, 195 insertions(+), 69 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 944e4d49..2284efe6 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -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", diff --git a/api/package.json b/api/package.json index 825ffa3c..70e5d59a 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/chat/schemas/types/context.ts b/api/src/chat/schemas/types/context.ts index 73514097..b5b4d0e7 100644 --- a/api/src/chat/schemas/types/context.ts +++ b/api/src/chat/schemas/types/context.ts @@ -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; attempt: number; } + +export interface TemplateContext { + context: Context; + contact: Settings['contact']; +} diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index fb4ad88f..eb15829f 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -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.'); + }); + }); }); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 04028bc8..c3340b7c 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -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); } /** diff --git a/frontend/src/components/visual-editor/form/inputs/ReplacementTokens.tsx b/frontend/src/components/visual-editor/form/inputs/ReplacementTokens.tsx index e1bbfd95..c1a15ad8 100644 --- a/frontend/src/components/visual-editor/form/inputs/ReplacementTokens.tsx +++ b/frontend/src/components/visual-editor/form/inputs/ReplacementTokens.tsx @@ -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) => ( @@ -76,7 +76,7 @@ function ReplacementTokens() { {userInfos.map((v, index) => ( @@ -87,7 +87,7 @@ function ReplacementTokens() { {userLocation.map((v, index) => ( @@ -100,7 +100,7 @@ function ReplacementTokens() { {(contactInfos || []).map((v, index) => (