mirror of
https://github.com/hexastack/hexabot
synced 2025-04-10 15:55:55 +00:00
feat: add handlebars template for text
This commit is contained in:
parent
e75d00f9f5
commit
e90a268b84
10
api/package-lock.json
generated
10
api/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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'];
|
||||
}
|
||||
|
@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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",
|
||||
})}
|
||||
|
Loading…
Reference in New Issue
Block a user