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

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);
}
/**