From 0b902031fae1615e8c49b0b182aaeb34bb835d56 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 21 Mar 2025 12:15:01 +0100 Subject: [PATCH 01/22] feat: add description attribute for nlu values --- api/src/nlp/dto/nlp-value.dto.ts | 12 +++++++++++- api/src/nlp/schemas/nlp-value.schema.ts | 8 +++++++- frontend/src/components/nlp/components/NlpValue.tsx | 8 ++++++++ .../src/components/nlp/components/NlpValueForm.tsx | 9 +++++++++ frontend/src/types/nlp-value.types.ts | 3 ++- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/api/src/nlp/dto/nlp-value.dto.ts b/api/src/nlp/dto/nlp-value.dto.ts index 7fecb0e5..dd729936 100644 --- a/api/src/nlp/dto/nlp-value.dto.ts +++ b/api/src/nlp/dto/nlp-value.dto.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. @@ -39,6 +39,11 @@ export class NlpValueCreateDto { @IsObject() metadata?: Record; + @ApiPropertyOptional({ type: String }) + @IsString() + @IsOptional() + doc?: string; + @ApiPropertyOptional({ description: 'Nlp value is builtin', type: Boolean }) @IsOptional() @IsBoolean() @@ -77,6 +82,11 @@ export class NlpValueUpdateDto { @IsObjectId({ message: 'Entity must be a valid ObjectId' }) entity?: string | null; + @ApiPropertyOptional({ type: String }) + @IsString() + @IsOptional() + doc?: string; + @ApiPropertyOptional({ description: 'Nlp value is builtin', type: Boolean }) @IsOptional() @IsBoolean() diff --git a/api/src/nlp/schemas/nlp-value.schema.ts b/api/src/nlp/schemas/nlp-value.schema.ts index a5f2e4be..5748ccf3 100644 --- a/api/src/nlp/schemas/nlp-value.schema.ts +++ b/api/src/nlp/schemas/nlp-value.schema.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. @@ -46,6 +46,12 @@ export class NlpValueStub extends BaseSchema { @Prop({ type: JSON, default: {} }) metadata: Record; + /** + * Description of the entity's value purpose. + */ + @Prop({ type: String }) + doc?: string; + /** * Either or not this value a built-in (either fixtures or shipped along with the 3rd party ai). */ diff --git a/frontend/src/components/nlp/components/NlpValue.tsx b/frontend/src/components/nlp/components/NlpValue.tsx index 1864eb43..7f365435 100644 --- a/frontend/src/components/nlp/components/NlpValue.tsx +++ b/frontend/src/components/nlp/components/NlpValue.tsx @@ -111,6 +111,14 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { disableColumnMenu: true, renderHeader, }, + { + flex: 3, + field: "doc", + headerName: t("label.doc"), + sortable: true, + disableColumnMenu: true, + renderHeader, + }, { flex: 3, field: "synonyms", diff --git a/frontend/src/components/nlp/components/NlpValueForm.tsx b/frontend/src/components/nlp/components/NlpValueForm.tsx index f066a62d..e9e154be 100644 --- a/frontend/src/components/nlp/components/NlpValueForm.tsx +++ b/frontend/src/components/nlp/components/NlpValueForm.tsx @@ -61,6 +61,7 @@ export const NlpValueForm: FC< >({ defaultValues: { value: data?.value || "", + doc: data?.doc || "", expressions: data?.expressions || [], }, }); @@ -84,6 +85,7 @@ export const NlpValueForm: FC< reset({ value: data.value, expressions: data.expressions, + doc: data.doc, }); } else { reset(); @@ -102,6 +104,13 @@ export const NlpValueForm: FC< {...register("value", validationRules.value)} /> + + + {canHaveSynonyms ? ( diff --git a/frontend/src/types/nlp-value.types.ts b/frontend/src/types/nlp-value.types.ts index 92e3de68..7b7a1e5e 100644 --- a/frontend/src/types/nlp-value.types.ts +++ b/frontend/src/types/nlp-value.types.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. @@ -15,6 +15,7 @@ export interface INlpValueAttributes { entity: string; foreign_id?: string; value: string; + doc?: string; expressions?: string[]; metadata?: Record; builtin?: boolean; From d17b920fcfcf6c5727c18ed64055604b0d7c9a6b Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 21 Mar 2025 13:40:51 +0100 Subject: [PATCH 02/22] feat: add grid display for NLU values based on types of NLU entity --- .../components/nlp/components/NlpValue.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/nlp/components/NlpValue.tsx b/frontend/src/components/nlp/components/NlpValue.tsx index 1864eb43..f43fc53f 100644 --- a/frontend/src/components/nlp/components/NlpValue.tsx +++ b/frontend/src/components/nlp/components/NlpValue.tsx @@ -81,6 +81,7 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { }, }); const [selectedNlpValues, setSelectedNlpValues] = useState([]); + const shouldIncludeSynonyms = nlpEntity?.lookups.includes("keywords"); const actionColumns = useActionColumns( EntityType.NLP_VALUE, [ @@ -102,6 +103,19 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { ], t("label.operations"), ); + const synonymsColumn = { + flex: 3, + field: "synonyms", + headerName: t("label.synonyms"), + sortable: true, + renderCell: (params) => { + return params.row?.expressions?.map((exp, index) => ( + + )); + }, + disableColumnMenu: true, + renderHeader, + }; const columns: GridColDef[] = [ { flex: 3, @@ -111,20 +125,7 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { disableColumnMenu: true, renderHeader, }, - { - flex: 3, - field: "synonyms", - headerName: t("label.synonyms"), - sortable: true, - renderCell: (params) => { - return params.row?.expressions?.map((exp, index) => ( - - )); - }, - disableColumnMenu: true, - renderHeader, - }, - + ...(shouldIncludeSynonyms ? [synonymsColumn] : []), { maxWidth: 140, field: "createdAt", From e0486fcece3a48451a0e1ef5e89b73947b1b8bee Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 21 Mar 2025 13:44:41 +0100 Subject: [PATCH 03/22] feat: re-adapt display condition --- frontend/src/components/nlp/components/NlpValue.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/nlp/components/NlpValue.tsx b/frontend/src/components/nlp/components/NlpValue.tsx index f43fc53f..7e9b38e3 100644 --- a/frontend/src/components/nlp/components/NlpValue.tsx +++ b/frontend/src/components/nlp/components/NlpValue.tsx @@ -81,7 +81,7 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { }, }); const [selectedNlpValues, setSelectedNlpValues] = useState([]); - const shouldIncludeSynonyms = nlpEntity?.lookups.includes("keywords"); + const shouldIncludeSynonyms = !nlpEntity?.lookups.includes("trait"); const actionColumns = useActionColumns( EntityType.NLP_VALUE, [ From 7b111868ed3f698d04560997b21ffffd2b59e40b Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 21 Mar 2025 15:16:58 +0100 Subject: [PATCH 04/22] feat: add support for search on nlp value description --- api/src/nlp/controllers/nlp-value.controller.ts | 6 ++++-- frontend/src/components/nlp/components/NlpValue.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/api/src/nlp/controllers/nlp-value.controller.ts b/api/src/nlp/controllers/nlp-value.controller.ts index 74a17d48..1cd3aadb 100644 --- a/api/src/nlp/controllers/nlp-value.controller.ts +++ b/api/src/nlp/controllers/nlp-value.controller.ts @@ -93,7 +93,9 @@ export class NlpValueController extends BaseController< @Get('count') async filterCount( @Query( - new SearchFilterPipe({ allowedFields: ['entity', 'value'] }), + new SearchFilterPipe({ + allowedFields: ['entity', 'value', 'doc'], + }), ) filters?: TFilterQuery, ) { @@ -142,7 +144,7 @@ export class NlpValueController extends BaseController< @Query(PopulatePipe) populate: string[], @Query( new SearchFilterPipe({ - allowedFields: ['entity', 'value'], + allowedFields: ['entity', 'value', 'doc'], }), ) filters: TFilterQuery, diff --git a/frontend/src/components/nlp/components/NlpValue.tsx b/frontend/src/components/nlp/components/NlpValue.tsx index 7f365435..5d9b79d0 100644 --- a/frontend/src/components/nlp/components/NlpValue.tsx +++ b/frontend/src/components/nlp/components/NlpValue.tsx @@ -55,7 +55,7 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { const canHaveSynonyms = nlpEntity?.lookups?.[0] === NlpLookups.keywords; const { onSearch, searchPayload } = useSearch({ $eq: [{ entity: entityId }], - $iLike: ["value"], + $or: ["doc","value"] }); const { dataGridProps } = useFind( { entity: EntityType.NLP_VALUE }, From f2ca00f1d82a58eb6b11891310e63e9c3490dd0b Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Fri, 21 Mar 2025 15:17:59 +0100 Subject: [PATCH 05/22] feat: change getDefaultSettings to be async --- api/src/chat/controllers/block.controller.ts | 39 +++++++++++-------- .../i18n/services/translation.service.spec.ts | 31 +++++++-------- api/src/i18n/services/translation.service.ts | 21 +++++----- api/src/plugins/base-block-plugin.ts | 2 +- 4 files changed, 50 insertions(+), 43 deletions(-) diff --git a/api/src/chat/controllers/block.controller.ts b/api/src/chat/controllers/block.controller.ts index f09de5dc..c490ed84 100644 --- a/api/src/chat/controllers/block.controller.ts +++ b/api/src/chat/controllers/block.controller.ts @@ -127,24 +127,29 @@ export class BlockController extends BaseController< try { const plugins = this.pluginsService .getAllByType(PluginType.block) - .map((p) => ({ - id: p.getName(), - namespace: p.getNamespace(), - template: { - ...p.template, - message: { - plugin: p.name, - args: p.getDefaultSettings().reduce( - (acc, setting) => { - acc[setting.label] = setting.value; - return acc; - }, - {} as { [key: string]: any }, - ), + .map(async (p) => { + const defaultSettings = await p.getDefaultSettings(); + + return { + id: p.getName(), + namespace: p.getNamespace(), + template: { + ...p.template, + message: { + plugin: p.name, + args: defaultSettings.reduce( + (acc, setting) => { + acc[setting.label] = setting.value; + return acc; + }, + {} as { [key: string]: any }, + ), + }, }, - }, - effects: typeof p.effects === 'object' ? Object.keys(p.effects) : [], - })); + effects: + typeof p.effects === 'object' ? Object.keys(p.effects) : [], + }; + }); return plugins; } catch (e) { this.logger.error(e); diff --git a/api/src/i18n/services/translation.service.spec.ts b/api/src/i18n/services/translation.service.spec.ts index 9e7a0565..6103d0ef 100644 --- a/api/src/i18n/services/translation.service.spec.ts +++ b/api/src/i18n/services/translation.service.spec.ts @@ -132,7 +132,7 @@ describe('TranslationService', () => { expect(strings).toEqual(['Test message', 'Fallback message']); }); - it('should return plugin-related strings from block message with translatable args', () => { + it('should return plugin-related strings from block message with translatable args', async () => { const block: Block = { name: 'Ollama Plugin', patterns: [], @@ -203,7 +203,7 @@ describe('TranslationService', () => { return '/mock/path'; } - getDefaultSettings() { + async getDefaultSettings() { return this.settings; } } @@ -215,8 +215,7 @@ describe('TranslationService', () => { .spyOn(pluginService, 'getPlugin') .mockImplementation(() => mockedPlugin); - const result = service.getBlockStrings(block); - + const result = await service.getBlockStrings(block); expect(result).toEqual(['String 2', 'String 3']); }); @@ -225,7 +224,7 @@ describe('TranslationService', () => { expect(strings).toEqual(['Global fallback message']); }); - it('should return an array of strings from a block with a quick reply message', () => { + it('should return an array of strings from a block with a quick reply message', async () => { const block = { id: 'blockId', name: 'Test Block', @@ -252,7 +251,7 @@ describe('TranslationService', () => { createdAt: new Date(), updatedAt: new Date(), } as Block; - const strings = service.getBlockStrings(block); + const strings = await service.getBlockStrings(block); expect(strings).toEqual([ 'Test message', 'Quick reply 1', @@ -261,7 +260,7 @@ describe('TranslationService', () => { ]); }); - it('should return an array of strings from a block with a button message', () => { + it('should return an array of strings from a block with a button message', async () => { const block = { id: 'blockId', name: 'Test Block', @@ -288,7 +287,7 @@ describe('TranslationService', () => { createdAt: new Date(), updatedAt: new Date(), } as Block; - const strings = service.getBlockStrings(block); + const strings = await service.getBlockStrings(block); expect(strings).toEqual([ 'Test message', 'Button 1', @@ -297,7 +296,7 @@ describe('TranslationService', () => { ]); }); - it('should return an array of strings from a block with a text message', () => { + it('should return an array of strings from a block with a text message', async () => { const block = { id: 'blockId', name: 'Test Block', @@ -314,11 +313,11 @@ describe('TranslationService', () => { createdAt: new Date(), updatedAt: new Date(), } as Block; - const strings = service.getBlockStrings(block); + const strings = await service.getBlockStrings(block); expect(strings).toEqual(['Test message', 'Fallback message']); }); - it('should return an array of strings from a block with a nested message object', () => { + it('should return an array of strings from a block with a nested message object', async () => { const block = { id: 'blockId', name: 'Test Block', @@ -337,11 +336,11 @@ describe('TranslationService', () => { createdAt: new Date(), updatedAt: new Date(), } as Block; - const strings = service.getBlockStrings(block); + const strings = await service.getBlockStrings(block); expect(strings).toEqual(['Test message', 'Fallback message']); }); - it('should handle different message formats in getBlockStrings', () => { + it('should handle different message formats in getBlockStrings', async () => { // Covers lines 54-60, 65 // Test with an array message (line 54-57) @@ -350,7 +349,7 @@ describe('TranslationService', () => { message: ['This is a text message'], options: { fallback: { message: ['Fallback message'] } }, } as Block; - const strings1 = service.getBlockStrings(block1); + const strings1 = await service.getBlockStrings(block1); expect(strings1).toEqual(['This is a text message', 'Fallback message']); // Test with an object message (line 58-60) @@ -359,7 +358,7 @@ describe('TranslationService', () => { message: { text: 'Another text message' }, options: { fallback: { message: ['Fallback message'] } }, } as Block; - const strings2 = service.getBlockStrings(block2); + const strings2 = await service.getBlockStrings(block2); expect(strings2).toEqual(['Another text message', 'Fallback message']); // Test a block without a fallback (line 65) @@ -368,7 +367,7 @@ describe('TranslationService', () => { message: { text: 'Another test message' }, options: {}, } as Block; - const strings3 = service.getBlockStrings(block3); + const strings3 = await service.getBlockStrings(block3); expect(strings3).toEqual(['Another test message']); }); }); diff --git a/api/src/i18n/services/translation.service.ts b/api/src/i18n/services/translation.service.ts index 93bea9af..4c68d40c 100644 --- a/api/src/i18n/services/translation.service.ts +++ b/api/src/i18n/services/translation.service.ts @@ -45,8 +45,9 @@ export class TranslationService extends BaseService { * * @returns An array of strings */ - getBlockStrings(block: Block): string[] { + async getBlockStrings(block: Block): Promise { let strings: string[] = []; + if (Array.isArray(block.message)) { // Text Messages strings = strings.concat(block.message); @@ -56,12 +57,11 @@ export class TranslationService extends BaseService { PluginType.block, block.message.plugin, ); + const defaultSettings = await plugin?.getDefaultSettings(); // plugin - Object.entries(block.message.args).forEach(([l, arg]) => { - const setting = plugin - ?.getDefaultSettings() - .find(({ label }) => label === l); + Object.entries(block.message.args).forEach(async ([l, arg]) => { + const setting = defaultSettings?.find(({ label }) => label === l); if (setting?.translatable) { if (Array.isArray(arg)) { // array of text @@ -123,10 +123,13 @@ export class TranslationService extends BaseService { if (blocks.length === 0) { return []; } - return blocks.reduce((acc, block) => { - const strings = this.getBlockStrings(block); - return acc.concat(strings); - }, [] as string[]); + const allStrings: string[] = []; + for (const block of blocks) { + const strings = await this.getBlockStrings(block); + allStrings.push(...strings); + // allStrings = allStrings.concat(strings); + } + return allStrings; } /** diff --git a/api/src/plugins/base-block-plugin.ts b/api/src/plugins/base-block-plugin.ts index 0755cab3..fa30a4a7 100644 --- a/api/src/plugins/base-block-plugin.ts +++ b/api/src/plugins/base-block-plugin.ts @@ -38,7 +38,7 @@ export abstract class BaseBlockPlugin< this.settings = require(path.join(this.getPath(), 'settings')).default; } - getDefaultSettings(): T { + async getDefaultSettings(): Promise { return this.settings; } From e58d55cc547d1d4f6fc24ae3ec61e0ee52d560ef Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 21 Mar 2025 15:20:31 +0100 Subject: [PATCH 06/22] feat: apply feedback --- api/src/nlp/dto/nlp-value.dto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/nlp/dto/nlp-value.dto.ts b/api/src/nlp/dto/nlp-value.dto.ts index dd729936..5c1c42d9 100644 --- a/api/src/nlp/dto/nlp-value.dto.ts +++ b/api/src/nlp/dto/nlp-value.dto.ts @@ -39,7 +39,7 @@ export class NlpValueCreateDto { @IsObject() metadata?: Record; - @ApiPropertyOptional({ type: String }) + @ApiPropertyOptional({ description: 'Nlp Value Description', type: String }) @IsString() @IsOptional() doc?: string; @@ -82,7 +82,7 @@ export class NlpValueUpdateDto { @IsObjectId({ message: 'Entity must be a valid ObjectId' }) entity?: string | null; - @ApiPropertyOptional({ type: String }) + @ApiPropertyOptional({ description: 'Nlp Value Description', type: String }) @IsString() @IsOptional() doc?: string; From 409422b2c486c9912c5afc2af705c54782a0f7a1 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Fri, 21 Mar 2025 15:21:10 +0100 Subject: [PATCH 07/22] fix: remove comment --- api/src/i18n/services/translation.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/i18n/services/translation.service.ts b/api/src/i18n/services/translation.service.ts index 4c68d40c..3f38367f 100644 --- a/api/src/i18n/services/translation.service.ts +++ b/api/src/i18n/services/translation.service.ts @@ -127,7 +127,6 @@ export class TranslationService extends BaseService { for (const block of blocks) { const strings = await this.getBlockStrings(block); allStrings.push(...strings); - // allStrings = allStrings.concat(strings); } return allStrings; } From ab33a320bc9c5076d264d970e9b3d104252f3722 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 21 Mar 2025 15:25:11 +0100 Subject: [PATCH 08/22] feat: fix typo --- frontend/src/components/nlp/components/NlpValue.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/nlp/components/NlpValue.tsx b/frontend/src/components/nlp/components/NlpValue.tsx index 5d9b79d0..894bee1c 100644 --- a/frontend/src/components/nlp/components/NlpValue.tsx +++ b/frontend/src/components/nlp/components/NlpValue.tsx @@ -55,7 +55,7 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { const canHaveSynonyms = nlpEntity?.lookups?.[0] === NlpLookups.keywords; const { onSearch, searchPayload } = useSearch({ $eq: [{ entity: entityId }], - $or: ["doc","value"] + $or: ["doc", "value"] }); const { dataGridProps } = useFind( { entity: EntityType.NLP_VALUE }, From db94b5c4d5c51fe52255b428a8b021568a7940aa Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Sat, 22 Mar 2025 17:08:06 +0100 Subject: [PATCH 09/22] fix: move canPopulate method to the base-repository --- api/src/utils/generics/base-controller.ts | 9 ++------- api/src/utils/generics/base-repository.ts | 4 ++-- api/src/utils/generics/base-service.ts | 4 ++++ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/api/src/utils/generics/base-controller.ts b/api/src/utils/generics/base-controller.ts index b948d487..bb427c71 100644 --- a/api/src/utils/generics/base-controller.ts +++ b/api/src/utils/generics/base-controller.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. @@ -32,12 +32,7 @@ export abstract class BaseController< * @return - True if all populate fields are allowed, otherwise false. */ protected canPopulate(populate: string[]): boolean { - return populate.some((p) => - this.service - .getRepository() - .getPopulate() - .includes(p as P), - ); + return this.service.canPopulate(populate); } /** diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index fe832990..42174e82 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -89,8 +89,8 @@ export abstract class BaseRepository< this.registerLifeCycleHooks(); } - getPopulate(): P[] { - return this.populate; + canPopulate(populate: string[]): boolean { + return populate.some((p) => this.populate.includes(p as P)); } getEventName(suffix: EHook) { diff --git a/api/src/utils/generics/base-service.ts b/api/src/utils/generics/base-service.ts index a7b04c55..3a74b202 100644 --- a/api/src/utils/generics/base-service.ts +++ b/api/src/utils/generics/base-service.ts @@ -34,6 +34,10 @@ export abstract class BaseService< return this.repository; } + canPopulate(populate: string[]): boolean { + return this.repository.canPopulate(populate); + } + async findOne( criteria: string | TFilterQuery, options?: ClassTransformOptions, From 10b39529b9e1dcf8f5fa6f127c38be0417fad136 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Sun, 23 Mar 2025 21:31:18 +0100 Subject: [PATCH 10/22] feat: envelope factory --- api/src/chat/helpers/envelope-builder.spec.ts | 121 +++++++ api/src/chat/helpers/envelope-builder.ts | 190 +++++++++++ api/src/chat/helpers/envelope-factory.spec.ts | 318 ++++++++++++++++++ api/src/chat/helpers/envelope-factory.ts | 271 +++++++++++++++ api/src/chat/schemas/types/message.ts | 14 +- api/src/chat/services/block.service.spec.ts | 245 -------------- api/src/chat/services/block.service.ts | 69 ++-- api/src/plugins/plugins.module.ts | 4 +- api/src/utils/helpers/safeRandom.spec.ts | 30 ++ api/src/utils/helpers/safeRandom.ts | 15 +- 10 files changed, 979 insertions(+), 298 deletions(-) create mode 100644 api/src/chat/helpers/envelope-builder.spec.ts create mode 100644 api/src/chat/helpers/envelope-builder.ts create mode 100644 api/src/chat/helpers/envelope-factory.spec.ts create mode 100644 api/src/chat/helpers/envelope-factory.ts create mode 100644 api/src/utils/helpers/safeRandom.spec.ts diff --git a/api/src/chat/helpers/envelope-builder.spec.ts b/api/src/chat/helpers/envelope-builder.spec.ts new file mode 100644 index 00000000..c106265a --- /dev/null +++ b/api/src/chat/helpers/envelope-builder.spec.ts @@ -0,0 +1,121 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { z } from 'zod'; + +import { + OutgoingMessageFormat, + StdOutgoingQuickRepliesEnvelope, + stdOutgoingQuickRepliesEnvelopeSchema, + StdOutgoingTextEnvelope, + stdOutgoingTextEnvelopeSchema, + stdOutgoingTextMessageSchema, +} from '../schemas/types/message'; +import { QuickReplyType } from '../schemas/types/quick-reply'; + +import { EnvelopeBuilder, getEnvelopeBuilder } from './envelope-builder'; + +describe('EnvelopeBuilder', () => { + it('should create a builder with chainable setters', () => { + const builder = EnvelopeBuilder( + OutgoingMessageFormat.text, + {}, + stdOutgoingTextEnvelopeSchema, + ); + + builder.text('Hello'); + + const result = builder.build(); + expect(result).toEqual({ + format: OutgoingMessageFormat.text, + message: { + text: 'Hello', + }, + }); + }); + + it('should retrieve current field values when no argument is provided', () => { + const builder = EnvelopeBuilder( + OutgoingMessageFormat.text, + {}, + stdOutgoingTextEnvelopeSchema, + ); + + builder.text('Hello world'); + // Retrieve current value with no argument + expect(builder.text()).toBe('Hello world'); + }); + + it('should append items to array fields with appendToX methods', () => { + const builder = EnvelopeBuilder( + OutgoingMessageFormat.quickReplies, + {}, + stdOutgoingQuickRepliesEnvelopeSchema, + ); + + builder.text('Choose an option'); + builder.appendToQuickReplies({ + content_type: QuickReplyType.text, + title: 'Yes', + payload: 'yes', + }); + builder.appendToQuickReplies({ + content_type: QuickReplyType.text, + title: 'No', + payload: 'no', + }); + + const result = builder.build(); + expect(result).toEqual({ + format: OutgoingMessageFormat.quickReplies, + message: { + text: 'Choose an option', + quickReplies: [ + { content_type: 'text', title: 'Yes', payload: 'yes' }, + { content_type: 'text', title: 'No', payload: 'no' }, + ], + }, + }); + }); + + it('should validate the final envelope on build and throw on invalid data', () => { + const builder = EnvelopeBuilder( + OutgoingMessageFormat.text, + {}, + stdOutgoingTextMessageSchema, + ); + + expect(() => builder.build()).toThrow(z.ZodError); + }); +}); + +describe('getEnvelopeBuilder', () => { + it('should return a builder for text format that passes validation with required field', () => { + const builder = getEnvelopeBuilder(OutgoingMessageFormat.text); + builder.text('Hello from text envelope!'); + + const envelope = builder.build(); + expect(envelope.format).toBe(OutgoingMessageFormat.text); + expect(envelope.message.text).toBe('Hello from text envelope!'); + }); + + it('should return a builder for quickReplies format that can append items', () => { + const builder = getEnvelopeBuilder(OutgoingMessageFormat.quickReplies); + builder.text('Pick an option'); + builder.appendToQuickReplies({ + content_type: QuickReplyType.text, + title: 'Option A', + payload: 'a', + }); + + const envelope = builder.build(); + expect(envelope.format).toBe(OutgoingMessageFormat.quickReplies); + expect(envelope.message.text).toBe('Pick an option'); + expect(envelope.message.quickReplies?.length).toBe(1); + }); +}); diff --git a/api/src/chat/helpers/envelope-builder.ts b/api/src/chat/helpers/envelope-builder.ts new file mode 100644 index 00000000..17cdb473 --- /dev/null +++ b/api/src/chat/helpers/envelope-builder.ts @@ -0,0 +1,190 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { z } from 'zod'; + +import { + OutgoingMessageFormat, + StdOutgoingAttachmentEnvelope, + stdOutgoingAttachmentEnvelopeSchema, + StdOutgoingButtonsEnvelope, + stdOutgoingButtonsEnvelopeSchema, + StdOutgoingEnvelope, + StdOutgoingListEnvelope, + stdOutgoingListEnvelopeSchema, + StdOutgoingMessageEnvelope, + StdOutgoingQuickRepliesEnvelope, + stdOutgoingQuickRepliesEnvelopeSchema, + StdOutgoingSystemEnvelope, + stdOutgoingSystemEnvelopeSchema, + StdOutgoingTextEnvelope, + stdOutgoingTextEnvelopeSchema, +} from '../schemas/types/message'; + +type ArrayKeys = { + [K in keyof T]: NonNullable extends Array ? K : never; +}[keyof T]; + +export type IEnvelopeBuilder = { + [k in keyof T['message']]-?: ((arg: T['message'][k]) => IEnvelopeBuilder) & + (() => T['message'][k]); +} & { + [K in ArrayKeys as `appendTo${Capitalize}`]: ( + item: NonNullable extends (infer U)[] ? U : never, + ) => IEnvelopeBuilder; +} & { + build(): T; +}; + +/** + * Builds an envelope object (containing a `format` and a `message` property) + * and returns a proxy-based builder interface with chainable setter methods. + * It also validates the final envelope against the provided `z.ZodSchema`. + * + * @param format - The format of the outgoing envelope. + * Corresponds to `format` on the generic type `T`. + * @param template - An optional initial message template. + * It will be merged as you set or append properties through the returned builder. + * @param schema - A Zod schema used to validate the final envelope object. + * @param factory - Envelope Factory which provides methods common methods. + * + * @returns A proxy-based builder object implementing `IEnvelopeBuilder`. It provides + * chainable setter methods for all message fields, an `appendToX` pattern for + * array fields, and a `build()` method to finalize and validate the envelope. + * + * @example + * // Build a simple text envelope: + * const env1 = EnvelopeBuilder(OutgoingMessageFormat.text) + * .text('Hello') + * .build(); + * + * @example + * // Build a text envelope with quick replies: + * const env2 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies) + * .text('Hello') + * .quickReplies([]) + * .build(); + * + * @example + * // Append multiple quickReplies items: + * const env3 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies) + * .text('Are you interested?') + * .appendToQuickReplies({ + * content_type: QuickReplyType.text, + * title: 'Yes', + * payload: 'yes', + * }) + * .appendToQuickReplies({ + * content_type: QuickReplyType.text, + * title: 'No', + * payload: 'no', + * }) + * .build(); + * + * @example + * // Build a system envelope with an outcome: + * const env4 = EnvelopeBuilder(OutgoingMessageFormat.system) + * .outcome('success') + * .build(); + */ +export function EnvelopeBuilder( + format: T['format'], + template: Partial = {}, + schema: z.ZodSchema, +): IEnvelopeBuilder { + let built: { format: T['format']; message: Partial } = { + format, + message: template, + }; + + const builder = new Proxy( + {}, + { + get(target, prop) { + if ('build' === prop) { + // No type information - just return the object. + return () => { + const result = schema.parse(built); + built = { + format, + message: template, + }; + return result; + }; + } + + if (typeof prop === 'string' && prop.startsWith('appendTo')) { + // e.g. "appendToButtons" => "Buttons" + const rawKey = prop.replace(/^appendTo/, ''); + // e.g. "Buttons" -> "buttons" + const messageKey = rawKey.charAt(0).toLowerCase() + rawKey.slice(1); + + return (item: unknown) => { + // Initialize the array if needed + if (!Array.isArray(built.message[messageKey])) { + built.message[messageKey] = []; + } + (built.message[messageKey] as unknown[]).push(item); + return builder; + }; + } + + return (...args: unknown[]): unknown => { + // If no arguments passed return current value. + if (0 === args.length) { + return built.message[prop.toString()]; + } + + const value = args[0]; + + built.message[prop.toString()] = value; + return builder; + }; + }, + }, + ); + + return builder as IEnvelopeBuilder; +} + +type EnvelopeTypeByFormat = + F extends OutgoingMessageFormat.text + ? StdOutgoingTextEnvelope + : F extends OutgoingMessageFormat.quickReplies + ? StdOutgoingQuickRepliesEnvelope + : F extends OutgoingMessageFormat.buttons + ? StdOutgoingButtonsEnvelope + : F extends OutgoingMessageFormat.attachment + ? StdOutgoingAttachmentEnvelope + : F extends OutgoingMessageFormat.carousel + ? StdOutgoingListEnvelope + : F extends OutgoingMessageFormat.list + ? StdOutgoingListEnvelope + : F extends OutgoingMessageFormat.system + ? StdOutgoingSystemEnvelope + : StdOutgoingMessageEnvelope; + +const ENVELOP_SCHEMAS_BY_FORMAT = { + [OutgoingMessageFormat.text]: stdOutgoingTextEnvelopeSchema, + [OutgoingMessageFormat.quickReplies]: stdOutgoingQuickRepliesEnvelopeSchema, + [OutgoingMessageFormat.buttons]: stdOutgoingButtonsEnvelopeSchema, + [OutgoingMessageFormat.attachment]: stdOutgoingAttachmentEnvelopeSchema, + [OutgoingMessageFormat.carousel]: stdOutgoingListEnvelopeSchema, + [OutgoingMessageFormat.list]: stdOutgoingListEnvelopeSchema, + [OutgoingMessageFormat.system]: stdOutgoingSystemEnvelopeSchema, +}; + +export const getEnvelopeBuilder = ( + format: F, +) => { + return EnvelopeBuilder>( + format, + {}, + ENVELOP_SCHEMAS_BY_FORMAT[format], + ); +}; diff --git a/api/src/chat/helpers/envelope-factory.spec.ts b/api/src/chat/helpers/envelope-factory.spec.ts new file mode 100644 index 00000000..e324b5c9 --- /dev/null +++ b/api/src/chat/helpers/envelope-factory.spec.ts @@ -0,0 +1,318 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { AttachmentPayload, FileType } from '../schemas/types/attachment'; +import { Button, ButtonType } from '../schemas/types/button'; +import { Context } from '../schemas/types/context'; +import { + ContentElement, + ContentPagination, + OutgoingMessageFormat, +} from '../schemas/types/message'; +import { ContentOptions } from '../schemas/types/options'; +import { StdQuickReply } from '../schemas/types/quick-reply'; + +import { EnvelopeFactory } from './envelope-factory'; + +// Mock getRandomElement to produce a predictable result in tests. +jest.mock('@/utils/helpers/safeRandom', () => ({ + getRandomElement: (array) => (Array.isArray(array) ? array[0] : array), +})); + +// Set up a dummy global Handlebars so that our template compilation works. +// This simple implementation replaces tokens of the form {{a.b.c}} by looking up the path. +(global as any).Handlebars = { + compile: (template: string) => (context: any) => + template.replace(/\{\{([^}]+)\}\}/g, (_, token: string) => { + const parts = token.trim().split('.'); + let value = context; + for (const part of parts) { + value = value[part]; + } + return value; + }), +}; + +describe('EnvelopeFactory', () => { + let factory: EnvelopeFactory; + let context: Context; + let settings: Settings; + let i18n: { t: jest.Mock }; + + beforeEach(async () => { + context = { + user: { language: 'en', first_name: 'John', last_name: 'Doe', id: '123' }, + vars: { phone: '123-456-7890' }, + } as unknown as Context; + settings = { + contact: { + company_name: 'John Inc.', + company_email: 'contact@john-inc.com', + }, + } as Settings; + // For testing, i18n.t simply returns the provided text unchanged. + i18n = { t: jest.fn((text: string) => text) }; + factory = new EnvelopeFactory(context, settings, i18n as any); + }); + + describe('toHandlebars (static)', () => { + 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 = EnvelopeFactory.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 = EnvelopeFactory.toHandlebars(input); + expect(result).toBe(input); + }); + + it('should handle strings with no braces at all', () => { + const input = 'Hello world, no braces here'; + const result = EnvelopeFactory.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 = EnvelopeFactory.toHandlebars(input); + expect(result).toBe('{{one}} {{two}} {{three}}'); + }); + }); + + describe('compileHandlerbarsTemplate', () => { + it('should replace tokens with context variables correctly', () => { + const text = + 'Hello {{context.user.first_name}} {{context.user.last_name}}, your phone is {{context.vars.phone}}'; + + const result = EnvelopeFactory.compileHandlerbarsTemplate( + text, + context, + 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', + subscriberVar: 'SubscriberValue', + }, + } as unknown as Context; + const settings = { + contact: {}, + } as unknown as Settings; + + const result = EnvelopeFactory.compileHandlerbarsTemplate( + text, + context, + 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.company_email}}'; + const result = EnvelopeFactory.compileHandlerbarsTemplate( + text, + context, + settings, + ); + expect(result).toBe('You can reach us at contact@john-inc.com'); + }); + + it('should handle no placeholders gracefully', () => { + const text = 'No placeholders here.'; + const result = EnvelopeFactory.compileHandlerbarsTemplate( + text, + context, + settings, + ); + expect(result).toBe('No placeholders here.'); + }); + }); + + describe('processText', () => { + it('should process text when a string is provided', () => { + const input = 'Hello {{context.user.first_name}}'; + const result = factory.processText(input); + expect(result).toBe('Hello John'); + expect(i18n.t).toHaveBeenCalledWith(input, { + lang: context.user.language, + defaultValue: input, + }); + }); + + it('should process text when an array is provided (using the first element)', () => { + const texts = ['Option1 {{context.user.first_name}}', 'Option2']; + const result = factory.processText(texts); + expect(result).toBe('Option1 John'); + }); + }); + + describe('buildTextEnvelope', () => { + it('should build a text envelope with processed text', () => { + const input = 'Hello {{context.user.first_name}}'; + const envelope = factory.buildTextEnvelope(input); + expect(envelope.format).toBe(OutgoingMessageFormat.text); + expect(envelope.message.text).toBe('Hello John'); + }); + }); + + describe('buildQuickRepliesEnvelope', () => { + it('should build a quick replies envelope with processed text and quick replies', () => { + const input = "Choose {{context.user.first_name}}'s option"; + const quickReplies = [ + { + content_type: 'text', + title: 'Yes {{contact.company_name}}', + payload: 'do_{{context.user.id}}', + }, + { + content_type: 'text', + title: 'No {{contact.company_name}}', + payload: 'dont_{{context.user.id}}', + }, + ] as StdQuickReply[]; + const envelope = factory.buildQuickRepliesEnvelope(input, quickReplies); + expect(envelope.format).toBe(OutgoingMessageFormat.quickReplies); + expect(envelope.message.text).toBe("Choose John's option"); + expect(envelope.message.quickReplies).toHaveLength(2); + expect(envelope.message.quickReplies[0].title).toBe('Yes John Inc.'); + expect(envelope.message.quickReplies[0].payload).toBe('do_123'); + expect(envelope.message.quickReplies[1].title).toBe('No John Inc.'); + expect(envelope.message.quickReplies[1].payload).toBe('dont_123'); + }); + }); + + describe('buildButtonsEnvelope', () => { + it('should build a buttons envelope with processed text and buttons', () => { + const input = 'Press a button'; + const buttons: Button[] = [ + { + type: ButtonType.postback, + title: 'Click {{contact.company_name}}', + payload: 'btn_{context.user.id}', + }, + { + type: ButtonType.web_url, + title: 'Visit {{contact.company_name}}', + url: 'https://example.com', + }, + ]; + const envelope = factory.buildButtonsEnvelope(input, buttons); + expect(envelope.format).toBe(OutgoingMessageFormat.buttons); + expect(envelope.message.text).toBe('Press a button'); + expect(envelope.message.buttons).toHaveLength(2); + // For a postback button, both title and payload are processed. + expect(envelope.message.buttons[0].title).toBe('Click John Inc.'); + // @ts-expect-error part of the test + expect(envelope.message.buttons[0].payload).toBe('btn_123'); + // For a non-postback button, only the title is processed. + expect(envelope.message.buttons[1].title).toBe('Visit John Inc.'); + // @ts-expect-error part of the test + expect(envelope.message.buttons[1].url).toBe('https://example.com'); + }); + }); + + describe('buildAttachmentEnvelope', () => { + it('should build an attachment envelope with the provided attachment and processed quick replies', () => { + const attachment = { + type: FileType.image, + payload: { + url: 'https://example.com/image.png', + }, + } as AttachmentPayload; + const quickReplies = [ + { + content_type: 'text', + title: 'Yes {contact.company_name}', + payload: 'do_{context.user.id}', + }, + ] as StdQuickReply[]; + const envelope = factory.buildAttachmentEnvelope( + attachment, + quickReplies, + ); + expect(envelope.format).toBe(OutgoingMessageFormat.attachment); + expect(envelope.message.attachment).toEqual(attachment); + expect(envelope.message.quickReplies).toHaveLength(1); + expect(envelope.message.quickReplies?.[0].title).toBe('Yes John Inc.'); + expect(envelope.message.quickReplies?.[0].payload).toBe('do_123'); + }); + }); + + describe('buildListEnvelope', () => { + it('should build a list (or carousel) envelope with options, elements and pagination', () => { + const elements = [ + { id: '1', title: 'Element 1' }, + { id: '2', title: 'Element 2' }, + { id: '3', title: 'Element 3' }, + ] as ContentElement[]; + // Test both carousel and list formats. + [OutgoingMessageFormat.carousel, OutgoingMessageFormat.list].forEach( + (format) => { + const options = { + buttons: [], + display: format, + limit: 3, + fields: { + title: 'title', + subtitle: '', + image_url: '', + }, + } as unknown as ContentOptions; + const pagination = { + total: 3, + skip: 0, + limit: 3, + } as ContentPagination; + + const envelope = factory.buildListEnvelope( + format as + | OutgoingMessageFormat.carousel + | OutgoingMessageFormat.list, + options, + elements, + pagination, + ); + expect(envelope.format).toBe(format); + expect(envelope.message.options).toEqual(options); + expect(envelope.message.elements).toEqual(elements); + expect(envelope.message.pagination).toEqual(pagination); + }, + ); + }); + }); + + describe('buildSystemEnvelope', () => { + it('should build a system envelope with outcome and data', () => { + const outcome = 'success'; + const data = { key: 'value' }; + const envelope = factory.buildSystemEnvelope(outcome, data); + expect(envelope.format).toBe(OutgoingMessageFormat.system); + expect(envelope.message.outcome).toBe(outcome); + expect(envelope.message.data).toEqual(data); + }); + }); +}); diff --git a/api/src/chat/helpers/envelope-factory.ts b/api/src/chat/helpers/envelope-factory.ts new file mode 100644 index 00000000..903302fc --- /dev/null +++ b/api/src/chat/helpers/envelope-factory.ts @@ -0,0 +1,271 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { I18nService } from '@/i18n/services/i18n.service'; +import { getRandomElement } from '@/utils/helpers/safeRandom'; + +import { AttachmentPayload } from '../schemas/types/attachment'; +import { Button, ButtonType } from '../schemas/types/button'; +import { Context, TemplateContext } from '../schemas/types/context'; +import { + ContentElement, + ContentPagination, + OutgoingMessageFormat, + StdOutgoingAttachmentEnvelope, + StdOutgoingButtonsEnvelope, + StdOutgoingListEnvelope, + StdOutgoingQuickRepliesEnvelope, + StdOutgoingSystemEnvelope, + StdOutgoingTextEnvelope, +} from '../schemas/types/message'; +import { ContentOptions } from '../schemas/types/options'; +import { StdQuickReply } from '../schemas/types/quick-reply'; + +import { getEnvelopeBuilder } from './envelope-builder'; + +export class EnvelopeFactory { + constructor( + protected readonly context: Context, + protected readonly settings: Settings, + protected readonly i18n: I18nService, + ) {} + + /** + * 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. + */ + static 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}}'); + } + + /** + * Compiles a handlebars template to replace tokens with their associated values in the provided text message + * + * `You phone number is {{context.vars.phone}}` + * Becomes + * `You phone number is 6354-543-534` + * + * @param text - Text message + * @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 + */ + static compileHandlerbarsTemplate( + text: string, + context: Context, + settings: Settings, + ): string { + // Build the template context for Handlebars to match our token paths + const templateContext: TemplateContext = { + context: { ...context }, + contact: { ...settings.contact }, + }; + + // Compile and run the Handlebars template + const compileTemplate = Handlebars.compile( + EnvelopeFactory.toHandlebars(text), + ); + return compileTemplate(templateContext); + } + + /** + * Processes the provided text or array of texts, localizes it based on the user's language settings, + * and then compiles it with the current context and settings using Handlebars templates. + * + * @param text - The text or an array of text strings to be processed. + * @returns - The processed and localized text. + */ + public processText(text: string | string[]): string { + let result = Array.isArray(text) ? getRandomElement(text) : text; + result = this.i18n.t(result, { + lang: this.context.user.language, + defaultValue: result, + }); + result = EnvelopeFactory.compileHandlerbarsTemplate( + result, + this.context, + this.settings, + ); + return result; + } + + /** + * Returns an envelope builder instance for the specified message format. + * + * @template F - The envelope message format extending OutgoingMessageFormat. + * @param format - The desired envelope message format. + * @returns A builder instance for creating envelopes of the specified format. + */ + getBuilder(format: F) { + return getEnvelopeBuilder(format); + } + + /** + * Builds a text envelope by processing the provided text. + * + * This method processes the input text for localization and template compilation, + * then builds a text envelope using the envelope builder. + * + * @param text - The text content or an array of text variants. + * @returns A finalized text envelope object. + */ + buildTextEnvelope(text: string | string[]): StdOutgoingTextEnvelope { + const builder = this.getBuilder(OutgoingMessageFormat.text); + const processedText = this.processText(text); + return builder.text(processedText).build(); + } + + /** + * Builds a quick replies envelope by processing the text and quick reply items. + * + * Processes the input text for localization and template compilation, then appends each + * processed quick reply (with localized title and payload) to the envelope before finalizing it. + * + * @param text - The text content or an array of text variants. + * @param quickReplies - An array of quick reply objects. + * @returns A finalized quick replies envelope object. + */ + buildQuickRepliesEnvelope( + text: string | string[], + quickReplies: StdQuickReply[], + ): StdOutgoingQuickRepliesEnvelope { + const builder = this.getBuilder(OutgoingMessageFormat.quickReplies); + const processedText = this.processText(text); + const envelope = builder.text(processedText); + + quickReplies.forEach((qr) => { + envelope.appendToQuickReplies({ + ...qr, + title: this.processText(qr.title), + payload: this.processText(qr.payload), + }); + }); + + return envelope.build(); + } + + /** + * Builds a buttons envelope by processing the text and button items. + * + * Processes the input text and iterates over the provided buttons. + * For postback buttons, both the title and payload are processed; for other button types, + * only the title is processed. Each processed button is then appended to the envelope. + * + * @param text - The text content or an array of text variants. + * @param buttons - An array of button objects. + * @returns A finalized buttons envelope object. + */ + buildButtonsEnvelope( + text: string | string[], + buttons: Button[], + ): StdOutgoingButtonsEnvelope { + const builder = this.getBuilder(OutgoingMessageFormat.buttons); + const processedText = this.processText(text); + const envelope = builder.text(processedText); + + buttons.forEach((btn) => { + if (btn.type === ButtonType.postback) { + envelope.appendToButtons({ + ...btn, + title: this.processText(btn.title), + payload: this.processText(btn.payload), + }); + } else { + envelope.appendToButtons({ + ...btn, + title: this.processText(btn.title), + }); + } + }); + + return envelope.build(); + } + + /** + * Builds an attachment envelope with the provided attachment payload. + * + * Sets the attachment on the envelope and appends any quick replies after processing + * them for localization and template compilation. + * + * @param attachment - The attachment payload object. + * @param quickReplies - Optional array of quick reply objects. + * @returns A finalized attachment envelope object. + */ + buildAttachmentEnvelope( + attachment: AttachmentPayload, + quickReplies: StdQuickReply[] = [], + ): StdOutgoingAttachmentEnvelope { + const builder = this.getBuilder(OutgoingMessageFormat.attachment); + const envelope = builder.attachment(attachment); + + quickReplies.forEach((qr) => { + envelope.appendToQuickReplies({ + ...qr, + title: this.processText(qr.title), + payload: this.processText(qr.payload), + }); + }); + + return envelope.build(); + } + + /** + * Builds a list or carousel envelope using the provided content options. + * + * This method builds a list envelope (applicable for both carousel and list formats) + * by setting options, elements, and pagination details on the envelope. + * + * @param format - The envelope format (carousel or list). + * @param options - Options for content presentation. + * @param elements - An array of content elements. + * @param pagination - Pagination details for the content. + * @returns A finalized list envelope object. + */ + buildListEnvelope( + format: OutgoingMessageFormat.carousel | OutgoingMessageFormat.list, + options: ContentOptions, + elements: ContentElement[], + pagination: ContentPagination, + ): StdOutgoingListEnvelope { + const builder = this.getBuilder(format); + return builder + .options(options) + .elements(elements) + .pagination(pagination) + .build(); + } + + /** + * Builds a system envelope with the specified outcome and optional data. + * + * Processes the provided outcome and additional data (if any) to create a system envelope. + * This envelope type is used for system-level messaging. + * + * @param outcome - The outcome message or status. + * @param data - Optional additional data to include in the envelope. + * @returns A finalized system envelope object. + */ + buildSystemEnvelope( + outcome: string | undefined, + data?: unknown, + ): StdOutgoingSystemEnvelope { + const builder = this.getBuilder(OutgoingMessageFormat.system); + return builder.outcome(outcome).data(data).build(); + } +} diff --git a/api/src/chat/schemas/types/message.ts b/api/src/chat/schemas/types/message.ts index b0ca494e..ef325b3d 100644 --- a/api/src/chat/schemas/types/message.ts +++ b/api/src/chat/schemas/types/message.ts @@ -117,14 +117,18 @@ export const contentElementSchema = z export type ContentElement = z.infer; +export const contentPaginationSchema = z.object({ + total: z.number(), + skip: z.number(), + limit: z.number(), +}); + +export type ContentPagination = z.infer; + export const stdOutgoingListMessageSchema = z.object({ options: contentOptionsSchema, elements: z.array(contentElementSchema), - pagination: z.object({ - total: z.number(), - skip: z.number(), - limit: z.number(), - }), + pagination: contentPaginationSchema, }); export type StdOutgoingListMessage = z.infer< diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index eb15829f..744c04f7 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -49,8 +49,6 @@ import { } from '@/utils/test/mocks/block'; import { contextBlankInstance, - contextEmailVarInstance, - contextGetStartedInstance, subscriberContextBlankInstance, } from '@/utils/test/mocks/conversation'; import { nlpEntitiesGreeting } from '@/utils/test/mocks/nlp'; @@ -64,9 +62,7 @@ import { Block, BlockModel } from '../schemas/block.schema'; import { Category, CategoryModel } from '../schemas/category.schema'; import { LabelModel } from '../schemas/label.schema'; import { FileType } from '../schemas/types/attachment'; -import { Context } from '../schemas/types/context'; import { StdOutgoingListMessage } from '../schemas/types/message'; -import { SubscriberContext } from '../schemas/types/subscriberContext'; import { CategoryRepository } from './../repositories/category.repository'; import { BlockService } from './block.service'; @@ -81,8 +77,6 @@ describe('BlockService', () => { let hasPreviousBlocks: Block; let contentService: ContentService; let contentTypeService: ContentTypeService; - let settingService: SettingService; - let settings: Settings; beforeAll(async () => { const module = await Test.createTestingModule({ @@ -151,7 +145,6 @@ describe('BlockService', () => { }).compile(); blockService = module.get(BlockService); contentService = module.get(ContentService); - settingService = module.get(SettingService); contentTypeService = module.get(ContentTypeService); categoryRepository = module.get(CategoryRepository); blockRepository = module.get(BlockRepository); @@ -160,7 +153,6 @@ describe('BlockService', () => { name: 'hasPreviousBlocks', }))!; block = (await blockRepository.findOne({ name: 'hasNextBlocks' }))!; - settings = await settingService.getSettings(); }); afterEach(jest.clearAllMocks); @@ -208,25 +200,6 @@ describe('BlockService', () => { }); }); - describe('getRandom', () => { - it('should get a random message', () => { - const messages = [ - 'Hello, this is Nour', - 'Oh ! How are you ?', - "Hmmm that's cool !", - 'Corona virus', - 'God bless you', - ]; - const result = blockService.getRandom(messages); - expect(messages).toContain(result); - }); - - it('should return undefined when trying to get a random message from an empty array', () => { - const result = blockService.getRandom([]); - expect(result).toBe(undefined); - }); - }); - describe('match', () => { const handlerMock = { getName: jest.fn(() => WEB_CHANNEL_NAME), @@ -511,222 +484,4 @@ describe('BlockService', () => { }); }); }); - - describe('processText', () => { - const context: Context = { - ...contextGetStartedInstance, - channel: 'web-channel', - text: '', - payload: undefined, - nlp: { entities: [] }, - vars: { age: 21, email: 'email@example.com' }, - user_location: { - address: { address: 'sangafora' }, - lat: 23, - lon: 16, - }, - user: subscriberWithoutLabels, - skip: { '1': 0 }, - attempt: 0, - }; - const subscriberContext: SubscriberContext = { - ...subscriberContextBlankInstance, - vars: { - phone: '123456789', - }, - }; - - it('should process empty text', () => { - const result = blockService.processText( - '', - context, - subscriberContext, - settings, - ); - expect(result).toEqual(''); - }); - - it('should process text translation', () => { - const translation = { en: 'Welcome', fr: 'Bienvenue' }; - const result = blockService.processText( - translation.en, - context, - subscriberContext, - settings, - ); - expect(result).toEqual(translation.fr); - }); - - it('should process text replacements with ontext vars', () => { - const result = blockService.processText( - '{{context.user.first_name}} {{context.user.last_name}}, email : {{context.vars.email}}', - contextEmailVarInstance, - subscriberContext, - settings, - ); - expect(result).toEqual('John Doe, email : email@example.com'); - }); - - it('should process text replacements with context vars', () => { - const result = blockService.processText( - '{{context.user.first_name}} {{context.user.last_name}}, phone : {{context.vars.phone}}', - contextEmailVarInstance, - subscriberContext, - settings, - ); - expect(result).toEqual('John Doe, phone : 123456789'); - }); - - it('should process text replacements with settings contact infos', () => { - const result = blockService.processText( - 'Trying the settings : the name of company is <<{{contact.company_name}}>>', - contextBlankInstance, - subscriberContext, - settings, - ); - expect(result).toEqual( - 'Trying the settings : the name of company is <>', - ); - }); - }); - - 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 c3340b7c..d48d0983 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -8,9 +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'; import { ChannelName } from '@/channel/types'; import { ContentService } from '@/cms/services/content.service'; @@ -23,14 +21,15 @@ import { PluginService } from '@/plugins/plugins.service'; import { PluginType } from '@/plugins/types'; import { SettingService } from '@/setting/services/setting.service'; import { BaseService } from '@/utils/generics/base-service'; -import { getRandom } from '@/utils/helpers/safeRandom'; +import { getRandomElement } from '@/utils/helpers/safeRandom'; import { BlockDto } from '../dto/block.dto'; +import { EnvelopeFactory } from '../helpers/envelope-factory'; 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, TemplateContext } from '../schemas/types/context'; +import { Context } from '../schemas/types/context'; import { BlockMessage, OutgoingMessageFormat, @@ -51,7 +50,6 @@ export class BlockService extends BaseService< constructor( readonly repository: BlockRepository, private readonly contentService: ContentService, - private readonly attachmentService: AttachmentService, private readonly settingService: SettingService, private readonly pluginService: PluginService, private readonly logger: LoggerService, @@ -372,23 +370,6 @@ 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 * @@ -409,26 +390,23 @@ export class BlockService extends BaseService< subscriberContext: SubscriberContext, settings: Settings, ): string { - // Build the template context for Handlebars to match our token paths - const templateContext: TemplateContext = { - context: { + return EnvelopeFactory.compileHandlerbarsTemplate( + text, + { ...context, vars: { ...(subscriberContext?.vars || {}), ...(context.vars || {}), }, }, - contact: { ...settings.contact }, - }; - - // Compile and run the Handlebars template - const compileTemplate = Handlebars.compile(this.toHandlebars(text)); - return compileTemplate(templateContext); + settings, + ); } /** * Translates and replaces tokens with context variables values * + * @deprecated use EnvelopeFactory.processText() instead * @param text - Text to process * @param context - The context object * @@ -440,32 +418,31 @@ export class BlockService extends BaseService< subscriberContext: SubscriberContext, settings: Settings, ): string { - // Translate - text = this.i18n.t(text, { - lang: context.user.language, - defaultValue: text, - }); - // Replace context tokens - text = this.processTokenReplacements( - text, - context, - subscriberContext, + const envelopeFactory = new EnvelopeFactory( + { + ...context, + vars: { + ...context.vars, + ...subscriberContext.vars, + }, + }, settings, + this.i18n, ); - return text; + + return envelopeFactory.processText(text); } /** * Return a randomly picked item of the array * + * @deprecated use helper getRandomElement() instead * @param array - Array of any type * * @returns A random item from the array */ getRandom(array: T[]): T { - return Array.isArray(array) - ? array[Math.floor(getRandom() * array.length)] - : array; + return getRandomElement(array); } /** @@ -515,7 +492,7 @@ export class BlockService extends BaseService< // Text Message // Get random message from array const text = this.processText( - this.getRandom(blockMessage), + getRandomElement(blockMessage), context, subscriberContext, settings, diff --git a/api/src/plugins/plugins.module.ts b/api/src/plugins/plugins.module.ts index d1080cf2..d9cf3211 100644 --- a/api/src/plugins/plugins.module.ts +++ b/api/src/plugins/plugins.module.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. @@ -18,6 +18,7 @@ import { BlockModel } from '@/chat/schemas/block.schema'; import { CmsModule } from '@/cms/cms.module'; import { ContentTypeModel } from '@/cms/schemas/content-type.schema'; import { ContentModel } from '@/cms/schemas/content.schema'; +import { NlpModule } from '@/nlp/nlp.module'; import { PluginService } from './plugins.service'; @@ -42,6 +43,7 @@ import { PluginService } from './plugins.service'; AttachmentModule, ChatModule, HttpModule, + NlpModule, ], providers: [PluginService], exports: [PluginService], diff --git a/api/src/utils/helpers/safeRandom.spec.ts b/api/src/utils/helpers/safeRandom.spec.ts new file mode 100644 index 00000000..29d8f299 --- /dev/null +++ b/api/src/utils/helpers/safeRandom.spec.ts @@ -0,0 +1,30 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { getRandomElement } from './safeRandom'; + +describe('safeRandom', () => { + describe('getRandomElement', () => { + it('should get a random message', () => { + const messages = [ + 'Hello, this is Nour', + 'Oh ! How are you ?', + "Hmmm that's cool !", + 'Corona virus', + 'God bless you', + ]; + const result = getRandomElement(messages); + expect(messages).toContain(result); + }); + + it('should return undefined when trying to get a random message from an empty array', () => { + const result = getRandomElement([]); + expect(result).toBe(undefined); + }); + }); +}); diff --git a/api/src/utils/helpers/safeRandom.ts b/api/src/utils/helpers/safeRandom.ts index 4c7d0b37..745fe3ef 100644 --- a/api/src/utils/helpers/safeRandom.ts +++ b/api/src/utils/helpers/safeRandom.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. @@ -15,3 +15,16 @@ import crypto from 'crypto'; */ export const getRandom = (): number => crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32; + +/** + * Return a randomly picked item of the array + * + * @param array - Array of any type + * + * @returns A random item from the array + */ +export const getRandomElement = (array: T[]): T => { + return Array.isArray(array) + ? array[Math.floor(getRandom() * array.length)] + : array; +}; From 1b6808852ef196917554e0a7c618e53127405255 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Sun, 23 Mar 2025 21:48:39 +0100 Subject: [PATCH 11/22] feat: add README file --- api/src/chat/helpers/README.md | 115 +++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 api/src/chat/helpers/README.md diff --git a/api/src/chat/helpers/README.md b/api/src/chat/helpers/README.md new file mode 100644 index 00000000..559ad6b4 --- /dev/null +++ b/api/src/chat/helpers/README.md @@ -0,0 +1,115 @@ +# Envelope Helpers : Envelope Builder & Envelope Factory + +The envelope helpers introduces two key components to streamline outgoing message envelope creation: the **Envelope Builder** and the **Envelope Factory**. Together, they offer a comprehensive solution for constructing, localizing, and validating messages in various formats with minimal boilerplate. + +--- + +## Overview + +- **Envelope Builder:** + A Proxy-based builder utility that provides chainable setter methods to dynamically create envelope objects. It validates the final envelope using Zod schemas and supports dynamic array handling through methods like `appendToQuickReplies`. + +- **Envelope Factory:** + A higher-level abstraction that builds on the Envelope Builder. It integrates localization and templating using Handlebars and provides convenience methods for constructing different types of envelopes (text, quick replies, buttons, attachments, lists/carousels, and system messages). + +--- + +## Key Features + +### Envelope Builder + +- **Chainable Setter Methods:** + Methods dynamically set and retrieve message properties, making envelope configuration both flexible and intuitive. + +- **Dynamic Array Handling:** + Provides special `appendToX` methods to easily add items to array fields (e.g., quick replies, buttons). + +- **Schema Validation:** + Uses Zod to validate envelopes against predefined schemas, ensuring message integrity and compliance with expected formats. + +- **Format-Specific Construction:** + Pre-configured through `getEnvelopeBuilder` to support various message formats (text, quick replies, buttons, attachment, carousel, list, and system). + +### Envelope Factory + +- **Template Conversion & Compilation:** + - **toHandlebars:** Converts legacy single-curly brace templates (e.g., `{context.user.name}`) into Handlebars-style (`{{context.user.name}}`). + - **compileHandlerbarsTemplate:** Compiles and processes these templates by injecting contextual data, allowing dynamic content generation. + +- **Localization:** + Processes input text for localization using an integrated i18n service, ensuring that messages are tailored to the user's language settings. + +- **Convenience Envelope Methods:** + Provides methods to build various envelope types: + - **buildTextEnvelope:** Processes and builds text envelopes. + - **buildQuickRepliesEnvelope:** Constructs quick reply messages with localized titles and payloads. + - **buildButtonsEnvelope:** Handles both postback and non-postback buttons with proper text processing. + - **buildAttachmentEnvelope:** Creates attachment envelopes with optional quick replies. + - **buildListEnvelope:** Builds list/carousel envelopes using provided content options, elements, and pagination. + - **buildSystemEnvelope:** Assembles system envelopes with outcomes and optional data. + +- **Integration with Envelope Builder:** + Utilizes the Envelope Builder internally to ensure type-safe envelope construction and validation. + +--- + +## Usage Examples + +### Using Envelope Builder Directly + +```typescript +// Build a simple text envelope: +const env1 = EnvelopeBuilder(OutgoingMessageFormat.text) + .text('Hello') + .build(); + +// Append multiple quick replies: +const env2 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies) + .text('Are you interested?') + .appendToQuickReplies({ + content_type: QuickReplyType.text, + title: 'Yes', + payload: 'yes', + }) + .appendToQuickReplies({ + content_type: QuickReplyType.text, + title: 'No', + payload: 'no', + }) + .build(); +``` + +### Using Envelope Factory + +```typescript +const envelopeFactory = new EnvelopeFactory(context, settings, i18nService); + +// Build a localized text envelope: +const textEnvelope = envelopeFactory.buildTextEnvelope('Hello, {context.user.name}!'); + +// Build a quick replies envelope with processed quick replies: +const quickRepliesEnvelope = envelopeFactory.buildQuickRepliesEnvelope( + 'Do you want to proceed?', + [ + { content_type: 'text', title: 'Yes', payload: 'yes' }, + { content_type: 'text', title: 'No', payload: 'no' } + ] +); +``` + +--- + +## Implementation Details + +- **Proxy-based Dynamic Methods:** + The Envelope Builder leverages JavaScript Proxies to intercept property accesses, enabling both setter and getter behaviors, as well as dynamic array appending for enhanced flexibility. + +- **Type Safety with Generics:** + Both components use TypeScript generics and strict typing to ensure that each envelope adheres to its specific format, reducing runtime errors and enforcing schema compliance. + +- **Localization & Templating:** + The Envelope Factory integrates with an i18n service and uses Handlebars to convert and compile message templates, ensuring that all messages are correctly localized and dynamically composed with context-specific data. + +- **Schema Mapping:** + A mapping between message formats and their corresponding Zod schemas guarantees that envelopes are built and validated against the correct structure. + From 96b6226a32e8ebf3ff45583c453e29a5bea334f4 Mon Sep 17 00:00:00 2001 From: Med Marrouchi Date: Mon, 24 Mar 2025 08:49:40 +0100 Subject: [PATCH 12/22] Update api/src/nlp/schemas/nlp-value.schema.ts --- api/src/nlp/schemas/nlp-value.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/nlp/schemas/nlp-value.schema.ts b/api/src/nlp/schemas/nlp-value.schema.ts index 5748ccf3..523eaa3e 100644 --- a/api/src/nlp/schemas/nlp-value.schema.ts +++ b/api/src/nlp/schemas/nlp-value.schema.ts @@ -49,7 +49,7 @@ export class NlpValueStub extends BaseSchema { /** * Description of the entity's value purpose. */ - @Prop({ type: String }) + @Prop({ type: String, default: '' }) doc?: string; /** From 3ca4ca0e2b0b9fb2dbff099f3af7137d586531fa Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 24 Mar 2025 09:57:38 +0100 Subject: [PATCH 13/22] fix: add missing import --- api/src/chat/helpers/envelope-factory.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/chat/helpers/envelope-factory.ts b/api/src/chat/helpers/envelope-factory.ts index 903302fc..98bbbf3c 100644 --- a/api/src/chat/helpers/envelope-factory.ts +++ b/api/src/chat/helpers/envelope-factory.ts @@ -6,6 +6,8 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import Handlebars from 'handlebars'; + import { I18nService } from '@/i18n/services/i18n.service'; import { getRandomElement } from '@/utils/helpers/safeRandom'; From 67c4bc753f2e660bb5f5ac8cc35a9d4eb944d246 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 24 Mar 2025 10:12:40 +0100 Subject: [PATCH 14/22] feat: re-adapt unit tests for schema change --- api/src/nlp/controllers/nlp-sample.controller.spec.ts | 3 ++- api/src/nlp/controllers/nlp-value.controller.spec.ts | 5 ++++- api/src/utils/test/fixtures/nlpvalue.ts | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/api/src/nlp/controllers/nlp-sample.controller.spec.ts b/api/src/nlp/controllers/nlp-sample.controller.spec.ts index 832e21da..2e82b180 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.spec.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. @@ -379,6 +379,7 @@ describe('NlpSampleController', () => { expressions: [], builtin: false, entity: priceValueEntity!.id, + doc: '', }; const textSample = { text: 'How much does a BMW cost?', diff --git a/api/src/nlp/controllers/nlp-value.controller.spec.ts b/api/src/nlp/controllers/nlp-value.controller.spec.ts index d78b336f..9108cf97 100644 --- a/api/src/nlp/controllers/nlp-value.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-value.controller.spec.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. @@ -157,6 +157,7 @@ describe('NlpValueController', () => { expressions: ['synonym1', 'synonym2'], metadata: { firstkey: 'firstvalue', secondKey: 1995 }, builtin: false, + doc: '', }; const result = await nlpValueController.create(value); expect(result).toEqualPayload(value); @@ -223,6 +224,7 @@ describe('NlpValueController', () => { value: 'updated', expressions: [], builtin: true, + doc: '', }; const result = await nlpValueController.updateOne( positiveValue!.id, @@ -241,6 +243,7 @@ describe('NlpValueController', () => { value: 'updated', expressions: [], builtin: true, + doc: '', }), ).rejects.toThrow(getUpdateOneError(NlpValue.name, jhonNlpValue!.id)); }); diff --git a/api/src/utils/test/fixtures/nlpvalue.ts b/api/src/utils/test/fixtures/nlpvalue.ts index fa882014..fe8714b6 100644 --- a/api/src/utils/test/fixtures/nlpvalue.ts +++ b/api/src/utils/test/fixtures/nlpvalue.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. @@ -19,30 +19,35 @@ export const nlpValueFixtures: NlpValueCreateDto[] = [ value: 'positive', expressions: [], builtin: true, + doc: '', }, { entity: '0', value: 'negative', expressions: [], builtin: true, + doc: '', }, { entity: '1', value: 'jhon', expressions: ['john', 'joohn', 'jhonny'], builtin: true, + doc: '', }, { entity: '0', value: 'greeting', expressions: ['heello', 'Hello', 'hi', 'heyy'], builtin: true, + doc: '', }, { entity: '0', value: 'goodbye', expressions: ['bye', 'bye bye'], builtin: true, + doc: '', }, ]; From bfd8f4170fe1230df2dc7126fb05b62443d07777 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 24 Mar 2025 10:30:17 +0100 Subject: [PATCH 15/22] fix: nlp-sample pagination --- api/src/nlp/controllers/nlp-sample.controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/src/nlp/controllers/nlp-sample.controller.ts b/api/src/nlp/controllers/nlp-sample.controller.ts index c4e156d3..2be1351b 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.ts @@ -181,7 +181,11 @@ export class NlpSampleController extends BaseController< */ @Get('count') async filterCount( - @Query(new SearchFilterPipe({ allowedFields: ['text', 'type'] })) + @Query( + new SearchFilterPipe({ + allowedFields: ['text', 'type', 'language'], + }), + ) filters?: TFilterQuery, ) { return await this.count(filters); From 481851f7131fa48e64592ca5dbdb1c08a95c733b Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 24 Mar 2025 11:11:37 +0100 Subject: [PATCH 16/22] fix: typo naming --- api/src/chat/helpers/README.md | 2 +- api/src/chat/helpers/envelope-factory.spec.ts | 10 +++++----- api/src/chat/helpers/envelope-factory.ts | 4 ++-- api/src/chat/services/block.service.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/src/chat/helpers/README.md b/api/src/chat/helpers/README.md index 559ad6b4..816a86e9 100644 --- a/api/src/chat/helpers/README.md +++ b/api/src/chat/helpers/README.md @@ -34,7 +34,7 @@ The envelope helpers introduces two key components to streamline outgoing messag - **Template Conversion & Compilation:** - **toHandlebars:** Converts legacy single-curly brace templates (e.g., `{context.user.name}`) into Handlebars-style (`{{context.user.name}}`). - - **compileHandlerbarsTemplate:** Compiles and processes these templates by injecting contextual data, allowing dynamic content generation. + - **compileHandlebarsTemplate:** Compiles and processes these templates by injecting contextual data, allowing dynamic content generation. - **Localization:** Processes input text for localization using an integrated i18n service, ensuring that messages are tailored to the user's language settings. diff --git a/api/src/chat/helpers/envelope-factory.spec.ts b/api/src/chat/helpers/envelope-factory.spec.ts index e324b5c9..b68f9765 100644 --- a/api/src/chat/helpers/envelope-factory.spec.ts +++ b/api/src/chat/helpers/envelope-factory.spec.ts @@ -92,12 +92,12 @@ describe('EnvelopeFactory', () => { }); }); - describe('compileHandlerbarsTemplate', () => { + describe('compileHandlebarsTemplate', () => { it('should replace tokens with context variables correctly', () => { const text = 'Hello {{context.user.first_name}} {{context.user.last_name}}, your phone is {{context.vars.phone}}'; - const result = EnvelopeFactory.compileHandlerbarsTemplate( + const result = EnvelopeFactory.compileHandlebarsTemplate( text, context, settings, @@ -121,7 +121,7 @@ describe('EnvelopeFactory', () => { contact: {}, } as unknown as Settings; - const result = EnvelopeFactory.compileHandlerbarsTemplate( + const result = EnvelopeFactory.compileHandlebarsTemplate( text, context, settings, @@ -133,7 +133,7 @@ describe('EnvelopeFactory', () => { it('should use contact from settings if provided', () => { const text = 'You can reach us at {{contact.company_email}}'; - const result = EnvelopeFactory.compileHandlerbarsTemplate( + const result = EnvelopeFactory.compileHandlebarsTemplate( text, context, settings, @@ -143,7 +143,7 @@ describe('EnvelopeFactory', () => { it('should handle no placeholders gracefully', () => { const text = 'No placeholders here.'; - const result = EnvelopeFactory.compileHandlerbarsTemplate( + const result = EnvelopeFactory.compileHandlebarsTemplate( text, context, settings, diff --git a/api/src/chat/helpers/envelope-factory.ts b/api/src/chat/helpers/envelope-factory.ts index 98bbbf3c..1435e585 100644 --- a/api/src/chat/helpers/envelope-factory.ts +++ b/api/src/chat/helpers/envelope-factory.ts @@ -68,7 +68,7 @@ export class EnvelopeFactory { * * @returns Text message with the tokens being replaced */ - static compileHandlerbarsTemplate( + static compileHandlebarsTemplate( text: string, context: Context, settings: Settings, @@ -99,7 +99,7 @@ export class EnvelopeFactory { lang: this.context.user.language, defaultValue: result, }); - result = EnvelopeFactory.compileHandlerbarsTemplate( + result = EnvelopeFactory.compileHandlebarsTemplate( result, this.context, this.settings, diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index d48d0983..01aa81e9 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -390,7 +390,7 @@ export class BlockService extends BaseService< subscriberContext: SubscriberContext, settings: Settings, ): string { - return EnvelopeFactory.compileHandlerbarsTemplate( + return EnvelopeFactory.compileHandlebarsTemplate( text, { ...context, From 8bef5e521b5c6ecc1f046669ab68f8e21f01087f Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 24 Mar 2025 11:13:37 +0100 Subject: [PATCH 17/22] fix: readme --- api/src/chat/helpers/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/chat/helpers/README.md b/api/src/chat/helpers/README.md index 816a86e9..ac3de66b 100644 --- a/api/src/chat/helpers/README.md +++ b/api/src/chat/helpers/README.md @@ -1,6 +1,6 @@ # Envelope Helpers : Envelope Builder & Envelope Factory -The envelope helpers introduces two key components to streamline outgoing message envelope creation: the **Envelope Builder** and the **Envelope Factory**. Together, they offer a comprehensive solution for constructing, localizing, and validating messages in various formats with minimal boilerplate. +The envelope helpers introduce two key components to streamline outgoing message envelope creation: the **Envelope Builder** and the **Envelope Factory**. Together, they offer a comprehensive solution for constructing, localizing, and validating messages in various formats with minimal boilerplate. --- From 408d50a5d38ed14e36f623d5904f81d7ae7c8ec7 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 24 Mar 2025 11:56:52 +0100 Subject: [PATCH 18/22] fix: base plugin getDefaultSettings --- api/src/plugins/base-block-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/plugins/base-block-plugin.ts b/api/src/plugins/base-block-plugin.ts index fa30a4a7..19d3d4f9 100644 --- a/api/src/plugins/base-block-plugin.ts +++ b/api/src/plugins/base-block-plugin.ts @@ -38,7 +38,7 @@ export abstract class BaseBlockPlugin< this.settings = require(path.join(this.getPath(), 'settings')).default; } - async getDefaultSettings(): Promise { + getDefaultSettings(): Promise | T { return this.settings; } From 992ae79fc3fcdd0551498dad98fbc53af52ac495 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 24 Mar 2025 11:59:18 +0100 Subject: [PATCH 19/22] fix: update block controller --- api/src/chat/controllers/block.controller.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/chat/controllers/block.controller.ts b/api/src/chat/controllers/block.controller.ts index c490ed84..92b48823 100644 --- a/api/src/chat/controllers/block.controller.ts +++ b/api/src/chat/controllers/block.controller.ts @@ -93,7 +93,7 @@ export class BlockController extends BaseController< * @returns An array containing the settings of the specified plugin. */ @Get('customBlocks/settings') - findSettings(@Query('plugin') pluginName: PluginName) { + async findSettings(@Query('plugin') pluginName: PluginName) { try { if (!pluginName) { throw new BadRequestException( @@ -110,7 +110,7 @@ export class BlockController extends BaseController< throw new NotFoundException('Plugin Not Found'); } - return plugin.getDefaultSettings(); + return await plugin.getDefaultSettings(); } catch (e) { this.logger.error('Unable to fetch plugin settings', e); throw e; @@ -123,7 +123,7 @@ export class BlockController extends BaseController< * @returns An array containing available custom blocks. */ @Get('customBlocks') - findAll() { + async findAll() { try { const plugins = this.pluginsService .getAllByType(PluginType.block) @@ -150,7 +150,7 @@ export class BlockController extends BaseController< typeof p.effects === 'object' ? Object.keys(p.effects) : [], }; }); - return plugins; + return await Promise.all(plugins); } catch (e) { this.logger.error(e); throw e; From ccbe954bef43596b9404075d67b7db3f174c8947 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 24 Mar 2025 12:03:50 +0100 Subject: [PATCH 20/22] fix: remove unnecessary async --- api/src/i18n/services/translation.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/i18n/services/translation.service.ts b/api/src/i18n/services/translation.service.ts index 3f38367f..c2adddd8 100644 --- a/api/src/i18n/services/translation.service.ts +++ b/api/src/i18n/services/translation.service.ts @@ -60,7 +60,7 @@ export class TranslationService extends BaseService { const defaultSettings = await plugin?.getDefaultSettings(); // plugin - Object.entries(block.message.args).forEach(async ([l, arg]) => { + Object.entries(block.message.args).forEach(([l, arg]) => { const setting = defaultSettings?.find(({ label }) => label === l); if (setting?.translatable) { if (Array.isArray(arg)) { From 172a167296bf07c6d0aeefc3ff23ad1ef9f179e5 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 24 Mar 2025 12:11:54 +0100 Subject: [PATCH 21/22] feat: define getters and setters --- api/src/chat/helpers/README.md | 8 ++-- api/src/chat/helpers/envelope-builder.spec.ts | 16 +++---- api/src/chat/helpers/envelope-builder.ts | 47 +++++++++++++------ api/src/chat/helpers/envelope-factory.spec.ts | 4 +- api/src/chat/helpers/envelope-factory.ts | 16 +++---- 5 files changed, 55 insertions(+), 36 deletions(-) diff --git a/api/src/chat/helpers/README.md b/api/src/chat/helpers/README.md index ac3de66b..883043ac 100644 --- a/api/src/chat/helpers/README.md +++ b/api/src/chat/helpers/README.md @@ -60,12 +60,12 @@ The envelope helpers introduce two key components to streamline outgoing message ```typescript // Build a simple text envelope: const env1 = EnvelopeBuilder(OutgoingMessageFormat.text) - .text('Hello') + .setText('Hello') .build(); // Append multiple quick replies: const env2 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies) - .text('Are you interested?') + .setText('Are you interested?') .appendToQuickReplies({ content_type: QuickReplyType.text, title: 'Yes', @@ -91,8 +91,8 @@ const textEnvelope = envelopeFactory.buildTextEnvelope('Hello, {context.user.nam const quickRepliesEnvelope = envelopeFactory.buildQuickRepliesEnvelope( 'Do you want to proceed?', [ - { content_type: 'text', title: 'Yes', payload: 'yes' }, - { content_type: 'text', title: 'No', payload: 'no' } + { content_type: QuickReplyType.text, title: 'Yes', payload: 'yes' }, + { content_type: QuickReplyType.text, title: 'No', payload: 'no' } ] ); ``` diff --git a/api/src/chat/helpers/envelope-builder.spec.ts b/api/src/chat/helpers/envelope-builder.spec.ts index c106265a..78b03532 100644 --- a/api/src/chat/helpers/envelope-builder.spec.ts +++ b/api/src/chat/helpers/envelope-builder.spec.ts @@ -28,7 +28,7 @@ describe('EnvelopeBuilder', () => { stdOutgoingTextEnvelopeSchema, ); - builder.text('Hello'); + builder.setText('Hello'); const result = builder.build(); expect(result).toEqual({ @@ -46,9 +46,9 @@ describe('EnvelopeBuilder', () => { stdOutgoingTextEnvelopeSchema, ); - builder.text('Hello world'); + builder.setText('Hello world'); // Retrieve current value with no argument - expect(builder.text()).toBe('Hello world'); + expect(builder.getText()).toBe('Hello world'); }); it('should append items to array fields with appendToX methods', () => { @@ -58,7 +58,7 @@ describe('EnvelopeBuilder', () => { stdOutgoingQuickRepliesEnvelopeSchema, ); - builder.text('Choose an option'); + builder.setText('Choose an option'); builder.appendToQuickReplies({ content_type: QuickReplyType.text, title: 'Yes', @@ -76,8 +76,8 @@ describe('EnvelopeBuilder', () => { message: { text: 'Choose an option', quickReplies: [ - { content_type: 'text', title: 'Yes', payload: 'yes' }, - { content_type: 'text', title: 'No', payload: 'no' }, + { content_type: QuickReplyType.text, title: 'Yes', payload: 'yes' }, + { content_type: QuickReplyType.text, title: 'No', payload: 'no' }, ], }, }); @@ -97,7 +97,7 @@ describe('EnvelopeBuilder', () => { describe('getEnvelopeBuilder', () => { it('should return a builder for text format that passes validation with required field', () => { const builder = getEnvelopeBuilder(OutgoingMessageFormat.text); - builder.text('Hello from text envelope!'); + builder.setText('Hello from text envelope!'); const envelope = builder.build(); expect(envelope.format).toBe(OutgoingMessageFormat.text); @@ -106,7 +106,7 @@ describe('getEnvelopeBuilder', () => { it('should return a builder for quickReplies format that can append items', () => { const builder = getEnvelopeBuilder(OutgoingMessageFormat.quickReplies); - builder.text('Pick an option'); + builder.setText('Pick an option'); builder.appendToQuickReplies({ content_type: QuickReplyType.text, title: 'Option A', diff --git a/api/src/chat/helpers/envelope-builder.ts b/api/src/chat/helpers/envelope-builder.ts index 17cdb473..692ff018 100644 --- a/api/src/chat/helpers/envelope-builder.ts +++ b/api/src/chat/helpers/envelope-builder.ts @@ -31,8 +31,11 @@ type ArrayKeys = { }[keyof T]; export type IEnvelopeBuilder = { - [k in keyof T['message']]-?: ((arg: T['message'][k]) => IEnvelopeBuilder) & - (() => T['message'][k]); + [K in keyof T['message'] as `set${Capitalize}`]-?: ( + arg: T['message'][K], + ) => IEnvelopeBuilder; +} & { + [K in keyof T['message'] as `get${Capitalize}`]-?: () => T['message'][K]; } & { [K in ArrayKeys as `appendTo${Capitalize}`]: ( item: NonNullable extends (infer U)[] ? U : never, @@ -41,6 +44,21 @@ export type IEnvelopeBuilder = { build(): T; }; +/** + * Extracts and transforms a property name into a standardized attribute name. + * + * @param prop - The property name from which to derive the attribute name. + * @param prefix - A regular expression that matches the prefix to remove from the property. + * @returns The transformed attribute name with its first character in lowercase. + */ +function getAttributeNameFromProp(prop: string, prefix: RegExp) { + // e.g. "appendToButtons" => "Buttons" + const rawKey = prop.toString().replace(prefix, ''); + // e.g. "Buttons" -> "buttons" + const messageKey = rawKey.charAt(0).toLowerCase() + rawKey.slice(1); + return messageKey; +} + /** * Builds an envelope object (containing a `format` and a `message` property) * and returns a proxy-based builder interface with chainable setter methods. @@ -60,20 +78,20 @@ export type IEnvelopeBuilder = { * @example * // Build a simple text envelope: * const env1 = EnvelopeBuilder(OutgoingMessageFormat.text) - * .text('Hello') + * .setText('Hello') * .build(); * * @example * // Build a text envelope with quick replies: * const env2 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies) - * .text('Hello') - * .quickReplies([]) + * .setText('Hello') + * .setQuickReplies([]) * .build(); * * @example * // Append multiple quickReplies items: * const env3 = EnvelopeBuilder(OutgoingMessageFormat.quickReplies) - * .text('Are you interested?') + * .setText('Are you interested?') * .appendToQuickReplies({ * content_type: QuickReplyType.text, * title: 'Yes', @@ -89,7 +107,7 @@ export type IEnvelopeBuilder = { * @example * // Build a system envelope with an outcome: * const env4 = EnvelopeBuilder(OutgoingMessageFormat.system) - * .outcome('success') + * .setOutcome('success') * .build(); */ export function EnvelopeBuilder( @@ -119,10 +137,7 @@ export function EnvelopeBuilder( } if (typeof prop === 'string' && prop.startsWith('appendTo')) { - // e.g. "appendToButtons" => "Buttons" - const rawKey = prop.replace(/^appendTo/, ''); - // e.g. "Buttons" -> "buttons" - const messageKey = rawKey.charAt(0).toLowerCase() + rawKey.slice(1); + const messageKey = getAttributeNameFromProp(prop, /^appendTo/); return (item: unknown) => { // Initialize the array if needed @@ -137,12 +152,16 @@ export function EnvelopeBuilder( return (...args: unknown[]): unknown => { // If no arguments passed return current value. if (0 === args.length) { - return built.message[prop.toString()]; + const messageKey = getAttributeNameFromProp( + prop.toString(), + /^get/, + ); + return built.message[messageKey]; } const value = args[0]; - - built.message[prop.toString()] = value; + const messageKey = getAttributeNameFromProp(prop.toString(), /^set/); + built.message[messageKey] = value; return builder; }; }, diff --git a/api/src/chat/helpers/envelope-factory.spec.ts b/api/src/chat/helpers/envelope-factory.spec.ts index b68f9765..a334104c 100644 --- a/api/src/chat/helpers/envelope-factory.spec.ts +++ b/api/src/chat/helpers/envelope-factory.spec.ts @@ -15,7 +15,7 @@ import { OutgoingMessageFormat, } from '../schemas/types/message'; import { ContentOptions } from '../schemas/types/options'; -import { StdQuickReply } from '../schemas/types/quick-reply'; +import { QuickReplyType, StdQuickReply } from '../schemas/types/quick-reply'; import { EnvelopeFactory } from './envelope-factory'; @@ -245,7 +245,7 @@ describe('EnvelopeFactory', () => { } as AttachmentPayload; const quickReplies = [ { - content_type: 'text', + content_type: QuickReplyType.text, title: 'Yes {contact.company_name}', payload: 'do_{context.user.id}', }, diff --git a/api/src/chat/helpers/envelope-factory.ts b/api/src/chat/helpers/envelope-factory.ts index 1435e585..8dab8656 100644 --- a/api/src/chat/helpers/envelope-factory.ts +++ b/api/src/chat/helpers/envelope-factory.ts @@ -130,7 +130,7 @@ export class EnvelopeFactory { buildTextEnvelope(text: string | string[]): StdOutgoingTextEnvelope { const builder = this.getBuilder(OutgoingMessageFormat.text); const processedText = this.processText(text); - return builder.text(processedText).build(); + return builder.setText(processedText).build(); } /** @@ -149,7 +149,7 @@ export class EnvelopeFactory { ): StdOutgoingQuickRepliesEnvelope { const builder = this.getBuilder(OutgoingMessageFormat.quickReplies); const processedText = this.processText(text); - const envelope = builder.text(processedText); + const envelope = builder.setText(processedText); quickReplies.forEach((qr) => { envelope.appendToQuickReplies({ @@ -179,7 +179,7 @@ export class EnvelopeFactory { ): StdOutgoingButtonsEnvelope { const builder = this.getBuilder(OutgoingMessageFormat.buttons); const processedText = this.processText(text); - const envelope = builder.text(processedText); + const envelope = builder.setText(processedText); buttons.forEach((btn) => { if (btn.type === ButtonType.postback) { @@ -214,7 +214,7 @@ export class EnvelopeFactory { quickReplies: StdQuickReply[] = [], ): StdOutgoingAttachmentEnvelope { const builder = this.getBuilder(OutgoingMessageFormat.attachment); - const envelope = builder.attachment(attachment); + const envelope = builder.setAttachment(attachment); quickReplies.forEach((qr) => { envelope.appendToQuickReplies({ @@ -247,9 +247,9 @@ export class EnvelopeFactory { ): StdOutgoingListEnvelope { const builder = this.getBuilder(format); return builder - .options(options) - .elements(elements) - .pagination(pagination) + .setOptions(options) + .setElements(elements) + .setPagination(pagination) .build(); } @@ -268,6 +268,6 @@ export class EnvelopeFactory { data?: unknown, ): StdOutgoingSystemEnvelope { const builder = this.getBuilder(OutgoingMessageFormat.system); - return builder.outcome(outcome).data(data).build(); + return builder.setOutcome(outcome).setData(data).build(); } } From 9ffd965069f2933666a727a128cb66aa8634fd7c Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 24 Mar 2025 12:16:11 +0100 Subject: [PATCH 22/22] fix: use enum --- api/src/chat/helpers/envelope-factory.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/chat/helpers/envelope-factory.spec.ts b/api/src/chat/helpers/envelope-factory.spec.ts index a334104c..6f9611d2 100644 --- a/api/src/chat/helpers/envelope-factory.spec.ts +++ b/api/src/chat/helpers/envelope-factory.spec.ts @@ -184,12 +184,12 @@ describe('EnvelopeFactory', () => { const input = "Choose {{context.user.first_name}}'s option"; const quickReplies = [ { - content_type: 'text', + content_type: QuickReplyType.text, title: 'Yes {{contact.company_name}}', payload: 'do_{{context.user.id}}', }, { - content_type: 'text', + content_type: QuickReplyType.text, title: 'No {{contact.company_name}}', payload: 'dont_{{context.user.id}}', },