mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: add handlebars template for text
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user