diff --git a/packages/api/.env.example b/packages/api/.env.example index 613196d2a..2f9d8bf5f 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -92,7 +92,7 @@ DB_PASSWORD=dev_only # Name of the Postgres database to use. DB_NAME=hexabot # Absolute path (inside the container) to the SQLite file when DB_TYPE=sqlite. -DB_SQLITE_PATH=/app/data/hexabot.sqlite +DB_SQLITE_PATH=./hexabot.sqlite # Optional connection string that overrides the discrete DB_* values when provided. # DB_URL=postgresql://dev_only:dev_only@postgres:5432/hexabot # Lets TypeORM auto-sync entities with the database schema (use only for dev). diff --git a/packages/api/README.md b/packages/api/README.md index 05b9c769c..aef31c780 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1,6 +1,6 @@ # Hexabot API -[Hexabot](https://hexabot.ai/)'s API is a RESTful API built with NestJS, designed to handle requests from both the UI admin panel and various communication channels. The API powers core functionalities such as chatbot management, message flow, NLU (Natural Language Understanding), and plugin integrations. +[Hexabot](https://hexabot.ai/)'s API is a RESTful API built with NestJS, designed to handle requests from both the UI admin panel and various communication channels. The API powers core functionalities such as chatbot management, message flow, NLU (Natural Language Understanding), and action-driven extensions. ## Key Features @@ -21,9 +21,9 @@ The API is divided into several key modules, each responsible for specific funct - **Chat:** The core module for handling incoming channel requests and managing the chat flow as defined by the visual editor in the UI. - **Knowledge Base:** Content management module for defining content types, managing content, and configuring menus for chatbot interactions. - **NLU:** Manages NLU (Natural Language Understanding) entities such as intents, languages, and values used to detect and process user inputs intelligently. -- **Plugins:** Manages extensions and plugins that integrate additional features and functionalities into the chatbot. +- **Actions:** Manages reusable actions that integrate additional features and drive agentic workflows. - **User:** Manages user authentication, roles, and permissions, ensuring secure access to different parts of the system. -- **Extensions:** A container for all types of extensions (channels, plugins, helpers) that can be added to expand the chatbot's functionality. +- **Extensions:** A container for all types of extensions (channels, actions, helpers) that can be added to expand the chatbot's functionality. - **Settings:** A module for management all types of settings that can be adjusted to customize the chatbot. ### Utility Modules diff --git a/packages/api/package.json b/packages/api/package.json index d5df275f0..d4516d2a3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -54,6 +54,7 @@ "*.{js,ts}": "eslint --fix --config eslint.config-staged.cjs" }, "dependencies": { + "@hexabot-ai/agentic": "workspace:*", "@keyv/redis": "^5.1.3", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/axios": "^4.0.1", @@ -184,7 +185,9 @@ "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { - "@/(.*)": "/$1" + "@/(.*)": "/$1", + "^@hexabot-ai/agentic$": "/../../agentic/src", + "^@hexabot-ai/agentic/(.*)$": "/../../agentic/src/$1" } }, "engines": { diff --git a/packages/api/src/actions/actions.module.ts b/packages/api/src/actions/actions.module.ts new file mode 100644 index 000000000..cb76b848f --- /dev/null +++ b/packages/api/src/actions/actions.module.ts @@ -0,0 +1,25 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { Global, Module } from '@nestjs/common'; +import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; + +import { ActionService } from './actions.service'; + +@Global() +@InjectDynamicProviders( + // Built-in core actions + 'node_modules/@hexabot-ai/api/dist/extensions/actions/**/*.action.js', + // Community extensions installed via npm + 'node_modules/hexabot-action-*/**/*.action.js', + // Custom & under dev actions + 'dist/extensions/actions/**/*.action.js', +) +@Module({ + providers: [ActionService], + exports: [ActionService], +}) +export class ActionsModule {} diff --git a/packages/api/src/actions/actions.service.spec.ts b/packages/api/src/actions/actions.service.spec.ts new file mode 100644 index 000000000..b474bbb52 --- /dev/null +++ b/packages/api/src/actions/actions.service.spec.ts @@ -0,0 +1,38 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { LoggerModule } from '@/logger/logger.module'; +import { DummyAction } from '@/utils/test/dummy/dummy.action'; +import { buildTestingMocks } from '@/utils/test/utils'; + +import { ActionService } from './actions.service'; +import { BaseAction } from './base-action'; + +describe('ActionService', () => { + let actionService: ActionService; + let dummyAction: InstanceType; + + beforeAll(async () => { + const { getMocks } = await buildTestingMocks({ + providers: [ActionService, DummyAction], + imports: [LoggerModule], + }); + [actionService, dummyAction] = await getMocks([ActionService, DummyAction]); + await dummyAction.onModuleInit(); + }); + + afterAll(jest.clearAllMocks); + + it('should return all registered actions', () => { + const actions = actionService.getAll(); + expect(actions.every((action) => action instanceof BaseAction)).toBe(true); + }); + + it('should fetch an action by name', () => { + const action = actionService.get('dummy_action'); + expect(action).toBeInstanceOf(DummyAction); + }); +}); diff --git a/packages/api/src/actions/actions.service.ts b/packages/api/src/actions/actions.service.ts new file mode 100644 index 000000000..52bb6b0ae --- /dev/null +++ b/packages/api/src/actions/actions.service.ts @@ -0,0 +1,49 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { BaseWorkflowContext } from '@hexabot-ai/agentic'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; + +import { LoggerService } from '@/logger/logger.service'; + +import { BaseAction } from './base-action'; +import { ActionName, ActionRegistry } from './types'; + +@Injectable() +export class ActionService { + private readonly registry: ActionRegistry< + BaseAction + > = new Map(); + + constructor(private readonly logger: LoggerService) {} + + register( + action: BaseAction, + ) { + const name = action.getName(); + if (this.registry.has(name)) { + throw new InternalServerErrorException( + `Action with name ${name} already exist`, + ); + } + + this.registry.set(name, action); + this.logger.log(`Action "${name}" has been registered!`); + } + + get(name: ActionName) { + const action = this.registry.get(name); + if (!action) { + throw new Error(`Unable to find action "${name}"`); + } + + return action; + } + + getAll() { + return Array.from(this.registry.values()); + } +} diff --git a/packages/api/src/actions/base-action.ts b/packages/api/src/actions/base-action.ts new file mode 100644 index 000000000..b14bfdfee --- /dev/null +++ b/packages/api/src/actions/base-action.ts @@ -0,0 +1,60 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { + AbstractAction, + ActionExecutionArgs, + ActionMetadata, + BaseWorkflowContext, + Settings, +} from '@hexabot-ai/agentic'; +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { I18nTranslation } from 'nestjs-i18n'; +import { Observable } from 'rxjs'; + +import { HyphenToUnderscore } from '@/utils/types/extension'; +import { WorkflowContext } from '@/workflow/services/workflow-context'; + +import { ActionService } from './actions.service'; +import { ActionName } from './types'; + +@Injectable() +export abstract class BaseAction< + I = unknown, + O = unknown, + C extends BaseWorkflowContext = WorkflowContext, + S extends Settings = Settings, + > + extends AbstractAction + implements OnModuleInit +{ + private translations?: I18nTranslation | Observable; + + protected constructor( + metadata: ActionMetadata, + private readonly actionService: ActionService, + ) { + super(metadata); + } + + getName(): ActionName { + return this.name as ActionName; + } + + getNamespace() { + return this.getName().replaceAll('-', '_') as HyphenToUnderscore; + } + + getTranslations() { + return this.translations; + } + + async onModuleInit() { + this.actionService.register(this); + } + + abstract execute(args: ActionExecutionArgs): Promise; +} diff --git a/packages/api/src/actions/create-action.ts b/packages/api/src/actions/create-action.ts new file mode 100644 index 000000000..62caba4c4 --- /dev/null +++ b/packages/api/src/actions/create-action.ts @@ -0,0 +1,61 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { + ActionExecutionArgs, + ActionMetadata, + BaseWorkflowContext, + Settings, +} from '@hexabot-ai/agentic'; +import { Injectable, Type } from '@nestjs/common'; + +import { WorkflowContext } from '@/workflow/services/workflow-context'; + +import { ActionService } from './actions.service'; +import { BaseAction } from './base-action'; + +type CreateActionParams< + I, + O, + C extends BaseWorkflowContext = WorkflowContext, + S extends Settings = Settings, +> = ActionMetadata & { + /** + * Optional path to the action folder. If omitted, it is resolved automatically + * from the caller's file location. + */ + path?: string; + execute: (args: ActionExecutionArgs) => Promise | O; +}; + +export function createAction< + I, + O, + C extends BaseWorkflowContext = WorkflowContext, + S extends Settings = Settings, +>(params: CreateActionParams): Type> { + @Injectable() + class FnAction extends BaseAction { + constructor(actionService: ActionService) { + super( + { + name: params.name, + description: params.description, + inputSchema: params.inputSchema, + outputSchema: params.outputSchema, + settingsSchema: params.settingsSchema, + }, + actionService, + ); + } + + async execute(args: ActionExecutionArgs) { + return params.execute(args); + } + } + + return FnAction; +} diff --git a/packages/api/src/actions/index.ts b/packages/api/src/actions/index.ts new file mode 100644 index 000000000..b4d179923 --- /dev/null +++ b/packages/api/src/actions/index.ts @@ -0,0 +1,15 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +export * from './actions.module'; + +export * from './actions.service'; + +export * from './base-action'; + +export * from './types'; + +export * from './create-action'; diff --git a/packages/api/src/actions/types.ts b/packages/api/src/actions/types.ts new file mode 100644 index 000000000..0cac5e3dc --- /dev/null +++ b/packages/api/src/actions/types.ts @@ -0,0 +1,17 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { Action, BaseWorkflowContext, Settings } from '@hexabot-ai/agentic'; + +import { WorkflowContext } from '@/workflow/services/workflow-context'; + +export type ActionName = `${string}_${string}`; + +export type AnyAction = Action; + +export type ActionRegistry< + A extends Action = AnyAction, +> = Map; diff --git a/packages/api/src/analytics/controllers/bot-stats.controller.spec.ts b/packages/api/src/analytics/controllers/bot-stats.controller.spec.ts index 7ba693d4a..f1ee6c3cd 100644 --- a/packages/api/src/analytics/controllers/bot-stats.controller.spec.ts +++ b/packages/api/src/analytics/controllers/bot-stats.controller.spec.ts @@ -132,33 +132,6 @@ describe('BotStatsController', () => { }); }); - describe('conversation', () => { - it('should return conversation messages', async () => { - const result = await botStatsController.conversation({ - from: new Date('2023-11-04T23:00:00.000Z'), - to: new Date('2023-11-06T23:00:00.000Z'), - }); - - expect(result).toEqualPayload([ - { - id: 1, - name: BotStatsType.new_conversations, - values: [ - { - ...botstatsFixtures[3], - date: botstatsFixtures[3].day, - }, - ], - }, - { - id: 2, - name: BotStatsType.existing_conversations, - values: [], - }, - ]); - }); - }); - describe('audiance', () => { it('should return audiance messages', async () => { const result = await botStatsController.audiance({ @@ -180,29 +153,22 @@ describe('BotStatsController', () => { { id: 2, name: BotStatsType.returning_users, - values: [], + values: [ + { + ...botstatsFixtures[2], + date: botstatsFixtures[2].day, + }, + ], }, { id: 3, name: BotStatsType.retention, - values: [], - }, - ]); - }); - }); - - describe('popularBlocks', () => { - it('should return popular blocks', async () => { - const result = await botStatsController.popularBlocks({ - from: new Date('2023-11-01T23:00:00.000Z'), - to: new Date('2023-11-08T23:00:00.000Z'), - }); - - expect(result).toEqual([ - { - name: 'Global Fallback', - id: 'Global Fallback', - value: 68, + values: [ + { + ...botstatsFixtures[3], + date: botstatsFixtures[3].day, + }, + ], }, ]); }); diff --git a/packages/api/src/analytics/controllers/bot-stats.controller.ts b/packages/api/src/analytics/controllers/bot-stats.controller.ts index 1ef50cdc7..f46e8fe86 100644 --- a/packages/api/src/analytics/controllers/bot-stats.controller.ts +++ b/packages/api/src/analytics/controllers/bot-stats.controller.ts @@ -72,28 +72,7 @@ export class BotStatsController extends BaseOrmController< } /** - * Retrieves conversation message stats within a specified time range - * - * @param dto - Parameters for filtering data (Start & End dates, Type). - * @returns A promise that resolves to an array of data formatted for the line chart. - */ - @Get('conversation') - async conversation( - @Query() - dto: BotStatsFindDto, - ): Promise { - const { from = aMonthAgo(), to = new Date() } = dto; - const types: BotStatsType[] = [ - BotStatsType.new_conversations, - BotStatsType.existing_conversations, - ]; - const result = await this.botStatsService.findMessages(from, to, types); - - return BotStatsOrmEntity.toLines(result, types); - } - - /** - * Retrieves audience message stats within a specified time range. + * Retrieves audience stats within a specified time range. * * @param dto - Parameters for filtering messages (Start & End dates). * @returns A promise that resolves to an array of data formatted for the line chart. @@ -113,21 +92,4 @@ export class BotStatsController extends BaseOrmController< return BotStatsOrmEntity.toLines(result, types); } - - /** - * Retrieves popular blocks stats within a specified time range. - * - * @param dto - Parameters for filtering messages (Start & End dates). - * @returns A promise that resolves to an array of data formatted for the bar chart. - */ - @Get('popularBlocks') - async popularBlocks( - @Query() - dto: BotStatsFindDto, - ): Promise<{ id: string; name: string; value: number }[]> { - const { from = aMonthAgo(), to = new Date() } = dto; - const results = await this.botStatsService.findPopularBlocks(from, to); - - return BotStatsOrmEntity.toBars(results); - } } diff --git a/packages/api/src/analytics/entities/bot-stats.entity.ts b/packages/api/src/analytics/entities/bot-stats.entity.ts index 921560dff..09a82bfc6 100644 --- a/packages/api/src/analytics/entities/bot-stats.entity.ts +++ b/packages/api/src/analytics/entities/bot-stats.entity.ts @@ -15,9 +15,6 @@ export enum BotStatsType { new_users = 'new_users', all_messages = 'all_messages', incoming = 'incoming', - existing_conversations = 'existing_conversations', - popular = 'popular', - new_conversations = 'new_conversations', returning_users = 'returning_users', retention = 'retention', echo = 'echo', diff --git a/packages/api/src/analytics/repositories/bot-stats.repository.spec.ts b/packages/api/src/analytics/repositories/bot-stats.repository.spec.ts index 6fb80729e..80cb8153b 100644 --- a/packages/api/src/analytics/repositories/bot-stats.repository.spec.ts +++ b/packages/api/src/analytics/repositories/bot-stats.repository.spec.ts @@ -73,17 +73,6 @@ describe('BotStatsRepository (TypeORM)', () => { expect(result).toEqualPayload([botstatsFixtures[0]]); }); - it('should return conversation statistics', async () => { - const from = new Date('2023-11-01T23:00:00.000Z'); - const to = new Date('2023-11-07T23:00:00.000Z'); - const result = await botStatsRepository.findMessages(from, to, [ - BotStatsType.new_conversations, - BotStatsType.existing_conversations, - ]); - - expect(result).toEqualPayload([botstatsFixtures[3]]); - }); - it('should return audiance statistics', async () => { const from = new Date('2023-11-01T23:00:00.000Z'); const to = new Date('2023-11-07T23:00:00.000Z'); @@ -93,7 +82,7 @@ describe('BotStatsRepository (TypeORM)', () => { BotStatsType.retention, ]); - expect(result).toEqualPayload([botstatsFixtures[1]]); + expect(result).toEqualPayload(botstatsFixtures.slice(1, 4)); }); it('should return statistics of a given type', async () => { @@ -106,19 +95,4 @@ describe('BotStatsRepository (TypeORM)', () => { expect(result).toEqualPayload([botstatsFixtures[4]]); }); }); - - describe('findPopularBlocks', () => { - it('should return popular blocks', async () => { - const from = new Date('2023-11-01T22:00:00.000Z'); - const to = new Date('2023-11-07T23:00:00.000Z'); - const result = await botStatsRepository.findPopularBlocks(from, to); - - expect(result).toEqual([ - { - id: 'Global Fallback', - value: 68, - }, - ]); - }); - }); }); diff --git a/packages/api/src/analytics/repositories/bot-stats.repository.ts b/packages/api/src/analytics/repositories/bot-stats.repository.ts index 6b41037e5..83480c729 100644 --- a/packages/api/src/analytics/repositories/bot-stats.repository.ts +++ b/packages/api/src/analytics/repositories/bot-stats.repository.ts @@ -62,35 +62,4 @@ export class BotStatsRepository extends BaseOrmRepository< }, }); } - - /** - * Retrieves the aggregated sum of values for popular blocks within a specified time range. - * - * @param from Start date for the time range - * @param to End date for the time range - * @param limit Optional maximum number of results to return (defaults to 5) - * @returns A promise that resolves to an array of objects containing the block ID and the aggregated value - */ - async findPopularBlocks( - from: Date, - to: Date, - limit: number = 5, - ): Promise<{ id: string; value: number }[]> { - if (from > to) { - return []; - } - - const results = await this.repository - .createQueryBuilder('stats') - .select('stats.name', 'id') - .addSelect('SUM(stats.value)', 'value') - .where('stats.type = :type', { type: BotStatsType.popular }) - .andWhere('stats.day BETWEEN :from AND :to', { from, to }) - .groupBy('stats.name') - .orderBy('value', 'DESC') - .limit(limit) - .getRawMany<{ id: string; value: number }>(); - - return results; - } } diff --git a/packages/api/src/analytics/services/bot-stats.service.spec.ts b/packages/api/src/analytics/services/bot-stats.service.spec.ts index 496c28be4..e93ddd950 100644 --- a/packages/api/src/analytics/services/bot-stats.service.spec.ts +++ b/packages/api/src/analytics/services/bot-stats.service.spec.ts @@ -120,21 +120,6 @@ describe('BotStatsService', () => { expect(result).toEqualPayload([botstatsFixtures[5]]); }); - it('should return messages of type conversation', async () => { - jest.spyOn(botStatsRepository, 'findMessages'); - const from = botstatsFixtures[0].day; - const to = new Date(); - const result = await botStatsService.findMessages(from, to, [ - BotStatsType.new_conversations, - BotStatsType.existing_conversations, - ]); - expect(botStatsRepository.findMessages).toHaveBeenCalledWith(from, to, [ - BotStatsType.new_conversations, - BotStatsType.existing_conversations, - ]); - expect(result).toEqualPayload([botstatsFixtures[3]]); - }); - it('should return messages of type audiance', async () => { jest.spyOn(botStatsRepository, 'findMessages'); const from = botstatsFixtures[0].day; @@ -149,27 +134,7 @@ describe('BotStatsService', () => { BotStatsType.returning_users, BotStatsType.retention, ]); - expect(result).toEqualPayload([botstatsFixtures[1]]); - }); - }); - - describe('findPopularBlocks', () => { - it('should return popular blocks', async () => { - jest.spyOn(botStatsRepository, 'findPopularBlocks'); - const from = botstatsFixtures[0].day; - const to = new Date(); - const result = await botStatsService.findPopularBlocks(from, to); - - expect(botStatsRepository.findPopularBlocks).toHaveBeenCalledWith( - from, - to, - ); - expect(result).toEqual([ - { - id: 'Global Fallback', - value: 68, - }, - ]); + expect(result).toEqualPayload(botstatsFixtures.slice(1, 4)); }); }); diff --git a/packages/api/src/analytics/services/bot-stats.service.ts b/packages/api/src/analytics/services/bot-stats.service.ts index 00c66666a..1f3689d7a 100644 --- a/packages/api/src/analytics/services/bot-stats.service.ts +++ b/packages/api/src/analytics/services/bot-stats.service.ts @@ -107,21 +107,6 @@ export class BotStatsService extends BaseOrmService< return await this.repository.findMessages(from, to, types); } - /** - * Retrieves the most popular blocks within a specified time range. - * Popular blocks are those triggered the most frequently. - * - * @param from - The start date of the time range. - * @param to - The end date of the time range. - * @returns A promise that resolves with an array of popular blocks, each containing an `id` and the number of times it was triggered (`value`). - */ - async findPopularBlocks( - from: Date, - to: Date, - ): Promise<{ id: string; value: number }[]> { - return await this.repository.findPopularBlocks(from, to); - } - /** * Handles the event to track user activity and emit statistics for loyalty, returning users, and retention. * diff --git a/packages/api/src/analytics/utilities/a-month-ago.spec.ts b/packages/api/src/analytics/utilities/a-month-ago.spec.ts index c04d3dfc3..87ccb2931 100644 --- a/packages/api/src/analytics/utilities/a-month-ago.spec.ts +++ b/packages/api/src/analytics/utilities/a-month-ago.spec.ts @@ -7,12 +7,19 @@ import { aMonthAgo } from './a-month-ago'; describe('aMonthAgo', () => { - it('should test the date from one month ago', () => { - const now = new Date(); + const now = new Date('2025-12-08T08:49:00.841Z'); + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(now); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the date from one month ago', () => { const result = aMonthAgo(); - expect(new Date(result.getTime())).toStrictEqual( - new Date(now.setMonth(now.getMonth() - 1)), - ); + expect(result).toStrictEqual(new Date('2025-11-08T08:49:00.841Z')); }); }); diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts index 8ee94745d..4265bd145 100644 --- a/packages/api/src/app.module.ts +++ b/packages/api/src/app.module.ts @@ -22,6 +22,7 @@ import { QueryResolver, } from 'nestjs-i18n'; +import { ActionsModule } from './actions/actions.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -39,11 +40,11 @@ import { LoggerModule } from './logger/logger.module'; import { MailerModule } from './mailer/mailer.module'; import { MigrationModule } from './migration/migration.module'; import { NlpModule } from './nlp/nlp.module'; -import { PluginsModule } from './plugins/plugins.module'; import { SettingModule } from './setting/setting.module'; import { Ability } from './user/guards/ability.guard'; import { UserModule } from './user/user.module'; import { WebsocketModule } from './websocket/websocket.module'; +import { WorkflowModule } from './workflow/workflow.module'; // Production "monolith" mode const compiledFrontendPath = join(__dirname, 'static'); @@ -86,7 +87,8 @@ export const HEXABOT_MODULE_IMPORTS: ModuleImports = [ AnalyticsModule, ChatModule, ChannelModule, - PluginsModule, + ActionsModule, + WorkflowModule, HelperModule, LoggerModule, WebsocketModule, diff --git a/packages/api/src/attachment/controllers/attachment.controller.spec.ts b/packages/api/src/attachment/controllers/attachment.controller.spec.ts index 9b9d2a667..1e838db9d 100644 --- a/packages/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/packages/api/src/attachment/controllers/attachment.controller.spec.ts @@ -138,7 +138,7 @@ describe('AttachmentController', () => { file: [], }, {} as Request, - { resourceRef: AttachmentResourceRef.BlockAttachment }, + { resourceRef: AttachmentResourceRef.MessageAttachment }, ); await expect(promiseResult).rejects.toThrow( new BadRequestException('No file was selected'), @@ -156,7 +156,7 @@ describe('AttachmentController', () => { { session: { passport: { user: { id: TEST_USER_ID } } }, } as unknown as Request, - { resourceRef: AttachmentResourceRef.BlockAttachment }, + { resourceRef: AttachmentResourceRef.MessageAttachment }, ); const [name] = attachmentFile.filename.split('.'); expect(attachmentService.create).toHaveBeenCalledWith({ @@ -164,7 +164,7 @@ describe('AttachmentController', () => { type: attachmentFile.mimetype, name: attachmentFile.originalname, location: expect.stringMatching(new RegExp(`^/${name}`)), - resourceRef: AttachmentResourceRef.BlockAttachment, + resourceRef: AttachmentResourceRef.MessageAttachment, access: AttachmentAccess.Public, createdByRef: AttachmentCreatedByRef.User, createdBy: TEST_USER_ID, @@ -173,7 +173,7 @@ describe('AttachmentController', () => { [ { ...attachment, - resourceRef: AttachmentResourceRef.BlockAttachment, + resourceRef: AttachmentResourceRef.MessageAttachment, createdByRef: AttachmentCreatedByRef.User, createdBy: TEST_USER_ID, }, diff --git a/packages/api/src/attachment/controllers/attachment.controller.ts b/packages/api/src/attachment/controllers/attachment.controller.ts index 493a21fc2..f2dcde478 100644 --- a/packages/api/src/attachment/controllers/attachment.controller.ts +++ b/packages/api/src/attachment/controllers/attachment.controller.ts @@ -201,7 +201,7 @@ export class AttachmentController extends BaseOrmController< /** * Deletion of attachments is disallowed to prevent database inconsistencies. - * Attachments may be referenced by blocks, messages, or content elements, + * Attachments may be referenced by actions, messages, or content elements, * and deleting them directly could lead to orphaned references or broken UI. * * @param id - The ID of the attachment (not used since deletion is not allowed). diff --git a/packages/api/src/attachment/guards/attachment-ability.guard.spec.ts b/packages/api/src/attachment/guards/attachment-ability.guard.spec.ts index a29386070..d4f49ef00 100644 --- a/packages/api/src/attachment/guards/attachment-ability.guard.spec.ts +++ b/packages/api/src/attachment/guards/attachment-ability.guard.spec.ts @@ -34,7 +34,6 @@ describe('AttachmentGuard', () => { const modelIdByIdentity: Partial> = { attachment: 'attachment-model-id', - block: 'block-model-id', content: 'content-model-id', message: 'message-model-id', setting: 'setting-model-id', @@ -179,7 +178,7 @@ describe('AttachmentGuard', () => { .spyOn(permissionService, 'findOne') .mockResolvedValue({} as Permission); const mockExecutionContext = buildContext({ - query: { resourceRef: AttachmentResourceRef.BlockAttachment }, + query: { resourceRef: AttachmentResourceRef.MessageAttachment }, method: 'POST', user: mockUser, }); @@ -188,7 +187,7 @@ describe('AttachmentGuard', () => { expect(modelFindOne).toHaveBeenCalledTimes(2); expect(modelFindOne).toHaveBeenNthCalledWith(1, { - where: { identity: 'block' }, + where: { identity: 'message' }, }); expect(modelFindOne).toHaveBeenNthCalledWith(2, { where: { identity: 'attachment' }, @@ -201,8 +200,8 @@ describe('AttachmentGuard', () => { expect(permissionWhereCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ - model: { id: modelIdByIdentity.block }, - action: Action.UPDATE, + model: { id: modelIdByIdentity.message }, + action: Action.CREATE, }), expect.objectContaining({ model: { id: modelIdByIdentity.attachment }, @@ -255,7 +254,7 @@ describe('AttachmentGuard', () => { expect(modelFindOne).toHaveBeenCalledTimes(2); expect(modelFindOne).toHaveBeenNthCalledWith(1, { - where: { identity: 'block' }, + where: { identity: 'message' }, }); expect(modelFindOne).toHaveBeenNthCalledWith(2, { where: { identity: 'attachment' }, @@ -268,7 +267,7 @@ describe('AttachmentGuard', () => { expect(permissionWhereCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ - model: { id: modelIdByIdentity.block }, + model: { id: modelIdByIdentity.message }, action: Action.UPDATE, }), expect.objectContaining({ diff --git a/packages/api/src/attachment/guards/attachment-ability.guard.ts b/packages/api/src/attachment/guards/attachment-ability.guard.ts index ee84b6ec1..4a46f8e03 100644 --- a/packages/api/src/attachment/guards/attachment-ability.guard.ts +++ b/packages/api/src/attachment/guards/attachment-ability.guard.ts @@ -51,10 +51,6 @@ export class AttachmentGuard implements CanActivate { ['attachment', Action.READ], ], [AttachmentResourceRef.UserAvatar]: [['user', Action.READ]], - [AttachmentResourceRef.BlockAttachment]: [ - ['block', Action.READ], - ['attachment', Action.READ], - ], [AttachmentResourceRef.ContentAttachment]: [ ['content', Action.READ], ['attachment', Action.READ], @@ -74,10 +70,6 @@ export class AttachmentGuard implements CanActivate { [AttachmentResourceRef.UserAvatar]: [ // Not authorized, done via /user/:id/edit endpoint ], - [AttachmentResourceRef.BlockAttachment]: [ - ['block', Action.UPDATE], - ['attachment', Action.CREATE], - ], [AttachmentResourceRef.ContentAttachment]: [ ['content', Action.UPDATE], ['attachment', Action.CREATE], @@ -100,10 +92,6 @@ export class AttachmentGuard implements CanActivate { [AttachmentResourceRef.UserAvatar]: [ // Not authorized ], - [AttachmentResourceRef.BlockAttachment]: [ - ['block', Action.UPDATE], - ['attachment', Action.DELETE], - ], [AttachmentResourceRef.ContentAttachment]: [ ['content', Action.UPDATE], ['attachment', Action.DELETE], @@ -112,14 +100,14 @@ export class AttachmentGuard implements CanActivate { // Not authorized, done programmatically by the channel ], [AttachmentResourceRef.MessageAttachment]: [ - // Not authorized + ['message', Action.UPDATE], + ['attachment', Action.DELETE], ], }, // Update attachments is not possible [Action.UPDATE]: { [AttachmentResourceRef.SettingAttachment]: [], [AttachmentResourceRef.UserAvatar]: [], - [AttachmentResourceRef.BlockAttachment]: [], [AttachmentResourceRef.ContentAttachment]: [], [AttachmentResourceRef.SubscriberAvatar]: [], [AttachmentResourceRef.MessageAttachment]: [], diff --git a/packages/api/src/attachment/mocks/attachment.mock.ts b/packages/api/src/attachment/mocks/attachment.mock.ts index 534a48985..526567f99 100644 --- a/packages/api/src/attachment/mocks/attachment.mock.ts +++ b/packages/api/src/attachment/mocks/attachment.mock.ts @@ -21,7 +21,7 @@ const baseAttachment = { size: 343370, location: '/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', - resourceRef: AttachmentResourceRef.BlockAttachment, + resourceRef: AttachmentResourceRef.MessageAttachment, access: AttachmentAccess.Public, createdBy: '1', createdByRef: AttachmentCreatedByRef.User, diff --git a/packages/api/src/attachment/repositories/attachment.repository.spec.ts b/packages/api/src/attachment/repositories/attachment.repository.spec.ts index ce6321ada..7f2ea18d4 100644 --- a/packages/api/src/attachment/repositories/attachment.repository.spec.ts +++ b/packages/api/src/attachment/repositories/attachment.repository.spec.ts @@ -112,7 +112,7 @@ describe('AttachmentRepository (TypeORM)', () => { type: 'image/png', size: 1234, location: `${suffix}.png`, - resourceRef: AttachmentResourceRef.BlockAttachment, + resourceRef: AttachmentResourceRef.MessageAttachment, access: AttachmentAccess.Public, createdByRef: AttachmentCreatedByRef.User, createdBy: CREATOR_UUID, diff --git a/packages/api/src/attachment/types/index.ts b/packages/api/src/attachment/types/index.ts index 0ee293398..40e32275d 100644 --- a/packages/api/src/attachment/types/index.ts +++ b/packages/api/src/attachment/types/index.ts @@ -23,7 +23,6 @@ export enum AttachmentResourceRef { SettingAttachment = 'Setting', // Attachments related to app settings, restricted to users with specific permissions. UserAvatar = 'User', // Avatar files for users, only the current user can upload, accessible to those with appropriate permissions. SubscriberAvatar = 'Subscriber', // Avatar files for subscribers, uploaded programmatically, accessible to authorized users. - BlockAttachment = 'Block', // Files sent by the bot, public or private based on the channel and user authentication. ContentAttachment = 'Content', // Files in the knowledge base, usually public but could vary based on specific needs. MessageAttachment = 'Message', // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.; } diff --git a/packages/api/src/channel/lib/__test__/common.mock.ts b/packages/api/src/channel/lib/__test__/common.mock.ts index 90930b38b..ffccff33a 100644 --- a/packages/api/src/channel/lib/__test__/common.mock.ts +++ b/packages/api/src/channel/lib/__test__/common.mock.ts @@ -94,7 +94,7 @@ const attachment: AttachmentOrmEntity = Object.assign( id: 'any-channel-attachment-id', }, }, - resourceRef: AttachmentResourceRef.BlockAttachment, + resourceRef: AttachmentResourceRef.MessageAttachment, access: AttachmentAccess.Public, createdByRef: AttachmentCreatedByRef.User, createdBy: null, diff --git a/packages/api/src/chat/chat.module.ts b/packages/api/src/chat/chat.module.ts index b0897fdc3..c68926e30 100644 --- a/packages/api/src/chat/chat.module.ts +++ b/packages/api/src/chat/chat.module.ts @@ -12,38 +12,26 @@ import { CmsModule } from '@/cms/cms.module'; import { NlpModule } from '@/nlp/nlp.module'; import { UserProfileOrmEntity } from '@/user/entities/user-profile.entity'; import { UserModule } from '@/user/user.module'; +import { WorkflowModule } from '@/workflow/workflow.module'; -import { BlockController } from './controllers/block.controller'; -import { CategoryController } from './controllers/category.controller'; import { ContextVarController } from './controllers/context-var.controller'; import { LabelGroupController } from './controllers/label-group.controller'; import { LabelController } from './controllers/label.controller'; import { MessageController } from './controllers/message.controller'; import { SubscriberController } from './controllers/subscriber.controller'; -import { BlockOrmEntity } from './entities/block.entity'; -import { CategoryOrmEntity } from './entities/category.entity'; import { ContextVarOrmEntity } from './entities/context-var.entity'; -import { ConversationOrmEntity } from './entities/conversation.entity'; import { LabelGroupOrmEntity } from './entities/label-group.entity'; import { LabelOrmEntity } from './entities/label.entity'; import { MessageOrmEntity } from './entities/message.entity'; import { SubscriberOrmEntity } from './entities/subscriber.entity'; -import { BlockRepository } from './repositories/block.repository'; -import { CategoryRepository } from './repositories/category.repository'; import { ContextVarRepository } from './repositories/context-var.repository'; -import { ConversationRepository } from './repositories/conversation.repository'; import { LabelGroupRepository } from './repositories/label-group.repository'; import { LabelRepository } from './repositories/label.repository'; import { MessageRepository } from './repositories/message.repository'; import { SubscriberRepository } from './repositories/subscriber.repository'; -import { CategorySeeder } from './seeds/category.seed'; import { ContextVarSeeder } from './seeds/context-var.seed'; -import { BlockService } from './services/block.service'; -import { BotService } from './services/bot.service'; -import { CategoryService } from './services/category.service'; import { ChatService } from './services/chat.service'; import { ContextVarService } from './services/context-var.service'; -import { ConversationService } from './services/conversation.service'; import { LabelGroupService } from './services/label-group.service'; import { LabelService } from './services/label.service'; import { MessageService } from './services/message.service'; @@ -53,58 +41,39 @@ import { SubscriberService } from './services/subscriber.service'; imports: [ TypeOrmModule.forFeature([ UserProfileOrmEntity, - CategoryOrmEntity, ContextVarOrmEntity, LabelOrmEntity, LabelGroupOrmEntity, - BlockOrmEntity, MessageOrmEntity, SubscriberOrmEntity, - ConversationOrmEntity, ]), CmsModule, AttachmentModule, UserModule, NlpModule, + WorkflowModule, ], controllers: [ - CategoryController, ContextVarController, LabelController, LabelGroupController, - BlockController, MessageController, SubscriberController, ], providers: [ - CategoryRepository, ContextVarRepository, LabelRepository, LabelGroupRepository, - BlockRepository, MessageRepository, SubscriberRepository, - ConversationRepository, - CategoryService, ContextVarService, LabelService, LabelGroupService, - BlockService, MessageService, SubscriberService, - CategorySeeder, ContextVarSeeder, - ConversationService, ChatService, - BotService, - ], - exports: [ - SubscriberService, - MessageService, - LabelService, - LabelGroupService, - BlockService, - ConversationService, ], + exports: [SubscriberService, MessageService, LabelService, LabelGroupService], }) export class ChatModule {} diff --git a/packages/api/src/chat/constants/block.ts b/packages/api/src/chat/constants/block.ts deleted file mode 100644 index ac7969514..000000000 --- a/packages/api/src/chat/constants/block.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { FallbackOptions } from '../types/options'; - -export function getDefaultFallbackOptions(): FallbackOptions { - return { - active: false, - max_attempts: 0, - message: [], - }; -} - -// Default maximum number of blocks returned by full-text search -export const DEFAULT_BLOCK_SEARCH_LIMIT = 500; diff --git a/packages/api/src/chat/constants/conversation.ts b/packages/api/src/chat/constants/conversation.ts deleted file mode 100644 index a3cdaedb3..000000000 --- a/packages/api/src/chat/constants/conversation.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Subscriber } from '../dto/subscriber.dto'; -import { Context } from '../types/context'; - -export function getDefaultConversationContext(): Context { - return { - vars: {}, // Used for capturing vars from user entries - channel: null, - text: null, - payload: null, - nlp: null, - user: { - firstName: '', - lastName: '', - // @TODO: Typing is not correct - } as Subscriber, - user_location: { - // Used for capturing geolocation from QR - lat: 0.0, - lon: 0.0, - }, - skip: {}, // Used for list pagination - attempt: 0, // Used to track fallback max attempts - }; -} diff --git a/packages/api/src/chat/controllers/block.controller.spec.ts b/packages/api/src/chat/controllers/block.controller.spec.ts deleted file mode 100644 index bc6770ee6..000000000 --- a/packages/api/src/chat/controllers/block.controller.spec.ts +++ /dev/null @@ -1,473 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { NotFoundException } from '@nestjs/common'; -import { TestingModule } from '@nestjs/testing'; - -import { ContentService } from '@/cms/services/content.service'; -import { LanguageService } from '@/i18n/services/language.service'; -import { NlpService } from '@/nlp/services/nlp.service'; -import { PluginService } from '@/plugins/plugins.service'; -import { UserService } from '@/user/services/user.service'; -import { - blockFixtures, - installBlockFixturesTypeOrm, -} from '@/utils/test/fixtures/block'; -import { installCategoryFixturesTypeOrm } from '@/utils/test/fixtures/category'; -import { installLabelFixturesTypeOrm } from '@/utils/test/fixtures/label'; -import { installNlpSampleEntityFixturesTypeOrm } from '@/utils/test/fixtures/nlpsampleentity'; -import { I18nServiceProvider } from '@/utils/test/providers/i18n-service.provider'; -import { closeTypeOrmConnections } from '@/utils/test/test'; -import { buildTestingMocks } from '@/utils/test/utils'; - -import { DEFAULT_BLOCK_SEARCH_LIMIT } from '../constants/block'; -import { - Block, - BlockCreateDto, - BlockFull, - BlockSearchQueryDto, - BlockUpdateDto, -} from '../dto/block.dto'; -import { Category } from '../dto/category.dto'; -import { BlockService } from '../services/block.service'; -import { CategoryService } from '../services/category.service'; -import { PayloadType } from '../types/button'; - -import { BlockController } from './block.controller'; - -const UNKNOWN_BLOCK_ID = '99999999-9999-4999-9999-999999999999'; -const DEFAULT_SETTINGS = { - chatbot_settings: { - global_fallback: true, - fallback_block: null, - }, -} as const; -const settingServiceMock = { - getSettings: jest.fn().mockResolvedValue(DEFAULT_SETTINGS), -}; -const pluginServiceMock = { - getPlugin: jest.fn(), - getAllByType: jest.fn().mockReturnValue([]), -}; -const userServiceMock = { - findOne: jest.fn().mockResolvedValue(null), -}; -const contentServiceMock = {}; -const languageServiceMock = {}; -const nlpServiceMock = {}; -const FIELDS_TO_POPULATE = [ - 'trigger_labels', - 'assign_labels', - 'nextBlocks', - 'attachedBlock', - 'category', - 'previousBlocks', -]; - -function createSearchQuery( - data: Partial, -): BlockSearchQueryDto { - return Object.assign(new BlockSearchQueryDto(), data); -} - -describe('BlockController (TypeORM)', () => { - let module: TestingModule; - let blockController: BlockController; - let blockService: BlockService; - let categoryService: CategoryService; - - let defaultCategory: Category; - let defaultBlock: Block; - let hasNextBlocks: Block; - let hasPreviousBlocks: Block; - - const buildBlockPayload = ( - overrides: Partial = {}, - ): BlockCreateDto => { - if (!defaultCategory) { - throw new Error('Category fixtures not loaded'); - } - - return { - name: `block-${Math.random().toString(36).slice(2, 10)}`, - nextBlocks: [], - patterns: ['Hi'], - outcomes: [], - trigger_labels: [], - assign_labels: [], - trigger_channels: [], - category: defaultCategory.id, - options: { - typing: 0, - fallback: { - active: false, - max_attempts: 1, - message: [], - }, - }, - message: ['Hi back !'], - starts_conversation: false, - capture_vars: [], - position: { - x: 0, - y: 0, - }, - ...overrides, - }; - }; - - beforeAll(async () => { - const testing = await buildTestingMocks({ - autoInjectFrom: ['controllers', 'providers'], - controllers: [BlockController], - providers: [ - CategoryService, - { provide: PluginService, useValue: pluginServiceMock }, - { provide: UserService, useValue: userServiceMock }, - { provide: ContentService, useValue: contentServiceMock }, - { provide: LanguageService, useValue: languageServiceMock }, - { provide: NlpService, useValue: nlpServiceMock }, - I18nServiceProvider, - ], - typeorm: { - fixtures: [ - installCategoryFixturesTypeOrm, - installLabelFixturesTypeOrm, - installBlockFixturesTypeOrm, - installNlpSampleEntityFixturesTypeOrm, - ], - }, - }); - - module = testing.module; - - [blockController, blockService, categoryService] = await testing.getMocks([ - BlockController, - BlockService, - CategoryService, - ]); - - const category = await categoryService.findOne({ - where: { label: 'default' }, - }); - if (!category) { - throw new Error('Expected "default" category fixture to be available'); - } - defaultCategory = category; - - const block = await blockService.findOne({ - where: { name: 'test' }, - }); - defaultBlock = block!; - - const nextBlock = await blockService.findOne({ - where: { name: 'hasNextBlocks' }, - }); - if (!nextBlock) { - throw new Error('Expected "hasNextBlocks" fixture to be available'); - } - hasNextBlocks = nextBlock; - - const previousBlock = await blockService.findOne({ - where: { name: 'hasPreviousBlocks' }, - }); - if (!previousBlock) { - throw new Error('Expected "hasPreviousBlocks" fixture to be available'); - } - hasPreviousBlocks = previousBlock; - }); - - beforeEach(() => { - pluginServiceMock.getAllByType.mockReturnValue([]); - pluginServiceMock.getPlugin.mockReturnValue(undefined); - settingServiceMock.getSettings.mockResolvedValue(DEFAULT_SETTINGS); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - afterAll(async () => { - if (module) { - await module.close(); - } - await closeTypeOrmConnections(); - }); - - describe('find', () => { - it('should return all blocks without populating relations when none requested', async () => { - const expected = await blockService.find({}); - const findSpy = jest - .spyOn(blockService, 'find') - .mockResolvedValue(expected); - const result = await blockController.find([], {} as any); - - expect(findSpy).toHaveBeenCalledWith({}); - expect(result).toBe(expected); - }); - - it('should populate relations when requested', async () => { - const expected = await blockService.findAndPopulate({}); - const findAndPopulateSpy = jest - .spyOn(blockService, 'findAndPopulate') - .mockResolvedValue(expected); - const result = await blockController.find(FIELDS_TO_POPULATE, {} as any); - - expect(findAndPopulateSpy).toHaveBeenCalledWith({}); - expect(result).toBe(expected); - }); - }); - - describe('search', () => { - it('should return empty array when query is empty', async () => { - const query = createSearchQuery({ q: '' }); - const result = await blockController.search(query); - expect(result).toEqual([]); - }); - - it('should delegate search to service with correct parameters', async () => { - const query = createSearchQuery({ - q: 'hasNextBlocks', - limit: 10, - category: defaultCategory.id, - }); - const searchSpy = jest.spyOn(blockService, 'search'); - const result = await blockController.search(query); - - expect(searchSpy).toHaveBeenCalledWith( - 'hasNextBlocks', - 10, - defaultCategory.id, - ); - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result.some((block) => block.name === 'hasNextBlocks')).toBe(true); - }); - - it('should handle service errors gracefully', async () => { - const error = new Error('Block search failed'); - jest.spyOn(blockService, 'search').mockRejectedValueOnce(error); - - const query = createSearchQuery({ q: 'error' }); - await expect(blockController.search(query)).rejects.toThrow( - 'Block search failed', - ); - }); - - it('should use default limit when not specified', async () => { - const searchSpy = jest.spyOn(blockService, 'search'); - const query = createSearchQuery({ q: 'hasNextBlocks' }); - await blockController.search(query); - - expect(searchSpy).toHaveBeenCalledWith( - 'hasNextBlocks', - DEFAULT_BLOCK_SEARCH_LIMIT, - undefined, - ); - }); - - it('should filter by category when provided', async () => { - const query = createSearchQuery({ - q: 'hasNextBlocks', - category: defaultCategory.id, - }); - const result = await blockController.search(query); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - result.forEach((block) => { - expect(block.category).toBe(defaultCategory.id); - }); - }); - }); - - describe('findOne', () => { - it('should find one block by id', async () => { - const findOneSpy = jest.spyOn(blockService, 'findOne'); - const result = await blockController.findOne(hasNextBlocks.id, []); - - expect(findOneSpy).toHaveBeenCalledWith(hasNextBlocks.id); - expect(result).toMatchObject({ - id: hasNextBlocks.id, - name: hasNextBlocks.name, - category: hasNextBlocks.category, - nextBlocks: hasNextBlocks.nextBlocks, - }); - }); - - it('should find one block by id and populate relations', async () => { - const findOneAndPopulateSpy = jest.spyOn( - blockService, - 'findOneAndPopulate', - ); - const result = await blockController.findOne( - hasPreviousBlocks.id, - FIELDS_TO_POPULATE, - ); - - expect(findOneAndPopulateSpy).toHaveBeenCalledWith(hasPreviousBlocks.id); - const populated = result as BlockFull; - expect(populated.category?.id).toBe(defaultCategory.id); - expect( - (populated.previousBlocks ?? []).some( - (block) => block.id === hasNextBlocks.id, - ), - ).toBe(true); - }); - - it('should find one attachment block with empty previousBlocks when populated', async () => { - const attachmentFixture = blockFixtures.find( - ({ name }) => name === 'attachment', - ); - if (!attachmentFixture) { - throw new Error('Expected "attachment" block fixture to be available'); - } - const attachmentBlock = await blockService.findOne({ - where: { name: 'attachment' }, - }); - if (!attachmentBlock) { - throw new Error('Expected "attachment" block to exist'); - } - - const result = (await blockController.findOne( - attachmentBlock.id, - FIELDS_TO_POPULATE, - )) as BlockFull; - - expect(result.category?.id).toBe(defaultCategory.id); - expect(result.previousBlocks).toEqual([]); - expect(result.attachedToBlock).toBeNull(); - expect(result.message).toEqual(attachmentFixture.message); - }); - }); - - describe('create', () => { - it('should return created block', async () => { - const createSpy = jest.spyOn(blockService, 'create'); - const payload = buildBlockPayload({ - name: 'block-with-next', - nextBlocks: [hasNextBlocks.id], - }); - const result = await blockController.create(payload); - - expect(createSpy).toHaveBeenCalledWith(payload); - expect(result).toMatchObject({ - name: payload.name, - category: payload.category, - nextBlocks: payload.nextBlocks, - }); - - await blockService.deleteOne(result.id); - }); - }); - - describe('deleteOne', () => { - it('should delete block', async () => { - const blockToDelete = await blockService.create( - buildBlockPayload({ name: 'block-to-delete' }), - ); - const deleteSpy = jest.spyOn(blockService, 'deleteOne'); - const result = await blockController.deleteOne(blockToDelete.id); - - expect(deleteSpy).toHaveBeenCalledWith(blockToDelete.id); - expect(result).toEqual({ acknowledged: true, deletedCount: 1 }); - }); - - it('should throw NotFoundException when attempting to delete a missing block', async () => { - const nonExistingId = UNKNOWN_BLOCK_ID; - - await expect(blockController.deleteOne(nonExistingId)).rejects.toThrow( - new NotFoundException(`Block with ID ${nonExistingId} not found`), - ); - }); - }); - - describe('updateOne', () => { - it('should return updated block', async () => { - const updateBlock: BlockUpdateDto = { - name: 'modified block name', - }; - const updateOneSpy = jest.spyOn(blockService, 'updateOne'); - const result = await blockController.updateOne( - defaultBlock.id, - updateBlock, - ); - - expect(updateOneSpy).toHaveBeenCalledWith(defaultBlock.id, updateBlock); - expect(result).toMatchObject({ - id: defaultBlock.id, - name: updateBlock.name, - category: defaultBlock.category, - }); - }); - - it('should throw NotFoundException when attempting to update a missing block', async () => { - const updateBlock: BlockUpdateDto = { - name: 'attempt to modify block name', - }; - - await expect( - blockController.updateOne(UNKNOWN_BLOCK_ID, updateBlock), - ).rejects.toThrow('Unable to execute updateOne() - No updates'); - }); - }); - - it('should update block trigger to postback menu', async () => { - const updateBlock: BlockUpdateDto = { - patterns: [ - { - label: 'postback123', - value: 'postback123', - type: PayloadType.menu, - }, - ], - }; - const updateOneSpy = jest.spyOn(blockService, 'updateOne'); - const result = await blockController.updateOne( - defaultBlock.id, - updateBlock, - ); - - expect(updateOneSpy).toHaveBeenCalledWith(defaultBlock.id, updateBlock); - expect( - result.patterns.find( - (pattern) => - typeof pattern === 'object' && - 'type' in pattern && - pattern.type === PayloadType.menu, - ), - ).toBeDefined(); - expect(result.patterns).toEqual(updateBlock.patterns); - }); - - it('should update the block trigger with a content payload type', async () => { - const updateBlock: BlockUpdateDto = { - patterns: [ - { - label: 'Content label', - value: 'Content value', - type: PayloadType.content, - }, - ], - }; - const updateOneSpy = jest.spyOn(blockService, 'updateOne'); - const result = await blockController.updateOne( - defaultBlock.id, - updateBlock, - ); - - expect(updateOneSpy).toHaveBeenCalledWith(defaultBlock.id, updateBlock); - expect( - result.patterns.find( - (pattern) => - typeof pattern === 'object' && - 'type' in pattern && - pattern.type === PayloadType.content, - ), - ).toBeDefined(); - expect(result.patterns).toEqual(updateBlock.patterns); - }); -}); diff --git a/packages/api/src/chat/controllers/block.controller.ts b/packages/api/src/chat/controllers/block.controller.ts deleted file mode 100644 index 1e10d1c39..000000000 --- a/packages/api/src/chat/controllers/block.controller.ts +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { - BadRequestException, - Body, - Controller, - Delete, - Get, - HttpCode, - NotFoundException, - Param, - Patch, - Post, - Query, -} from '@nestjs/common'; -import { FindManyOptions, In } from 'typeorm'; - -import { BaseBlockPlugin } from '@/plugins/base-block-plugin'; -import { PluginService } from '@/plugins/plugins.service'; -import { PluginName, PluginType } from '@/plugins/types'; -import { UserService } from '@/user/services/user.service'; -import { BaseOrmController } from '@/utils/generics/base-orm.controller'; -import { DeleteResult } from '@/utils/generics/base-orm.repository'; -import { PopulatePipe } from '@/utils/pipes/populate.pipe'; -import { TypeOrmSearchFilterPipe } from '@/utils/pipes/typeorm-search-filter.pipe'; - -import { - Block, - BlockCreateDto, - BlockDtoConfig, - BlockFull, - BlockSearchQueryDto, - BlockTransformerDto, - BlockUpdateDto, - SearchRankedBlock, -} from '../dto/block.dto'; -import { BlockOrmEntity } from '../entities/block.entity'; -import { BlockService } from '../services/block.service'; - -@Controller('Block') -export class BlockController extends BaseOrmController< - BlockOrmEntity, - BlockTransformerDto, - BlockDtoConfig -> { - constructor( - private readonly blockService: BlockService, - private readonly userService: UserService, - private pluginsService: PluginService>, - ) { - super(blockService); - } - - /** - * Text search for blocks. - * - * Example: GET /block/search?q=UserSearchPhrase&limit=50 - * - * @param {string} q - The search term. - * @param {number} [limit] - The maximum number of results to return. - * @param {string} [category] - The category to filter the search results. - * @returns {Promise} A promise that resolves to an array of ranked block search results. - */ - @Get('search') - async search( - @Query() - { q, limit, category }: BlockSearchQueryDto, - ): Promise { - if (!q) return []; - - return await this.blockService.search(q, limit, category); - } - - /** - * Finds blocks based on the provided query parameters. - * @param populate - An array of fields to populate in the returned blocks. - * @param options - Combined filters, pagination, and sorting for the query. - * @returns A Promise that resolves to an array of found blocks. - */ - @Get() - async find( - @Query(PopulatePipe) - populate: string[], - @Query( - new TypeOrmSearchFilterPipe({ - allowedFields: [ - 'category.id', - 'name', - 'starts_conversation', - 'builtin', - ], - defaultSort: ['createdAt', 'desc'], - }), - ) - options: FindManyOptions, - ): Promise { - const queryOptions = options ?? {}; - - return this.canPopulate(populate) - ? await this.blockService.findAndPopulate(queryOptions) - : await this.blockService.find(queryOptions); - } - - /** - * Retrieves a custom block settings for a specific plugin. - * - * @param pluginName - The name of the plugin for which settings are to be retrieved. - * - * @returns An array containing the settings of the specified plugin. - */ - @Get('customBlocks/settings') - async findSettings(@Query('plugin') pluginName: PluginName) { - try { - if (!pluginName) { - throw new BadRequestException( - 'Plugin name must be supplied as a query param', - ); - } - - const plugin = this.pluginsService.getPlugin( - PluginType.block, - pluginName, - ); - - if (!plugin) { - throw new NotFoundException('Plugin Not Found'); - } - - return await plugin.getDefaultSettings(); - } catch (e) { - this.logger.error('Unable to fetch plugin settings', e); - throw e; - } - } - - /** - * Retrieves all custom blocks (plugins) along with their associated block template. - * - * @returns An array containing available custom blocks. - */ - @Get('customBlocks') - async findAll() { - try { - const plugins = this.pluginsService - .getAllByType(PluginType.block) - .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) : [], - }; - }); - - return await Promise.all(plugins); - } catch (e) { - this.logger.error(e); - throw e; - } - } - - // @TODO : remove once old frontend is abandoned - /** - * Retrieves the effects of all plugins that have effects defined. - * - * @returns An array containing objects representing the effects of plugins. - */ - @Get('effects') - findEffects(): { - name: string; - title: any; - }[] { - try { - const plugins = this.pluginsService.getAllByType(PluginType.block); - const effects = Object.keys(plugins) - .filter( - (plugin) => - typeof plugins[plugin].effects === 'object' && - Object.keys(plugins[plugin].effects).length > 0, - ) - .map((plugin) => ({ - name: plugin, - title: plugins[plugin].title, - })); - - return effects; - } catch (e) { - this.logger.error(e); - throw e; - } - } - - /** - * Retrieves a single block by its ID. - * - * @param id - The ID of the block to retrieve. - * @param populate - An array of fields to populate in the retrieved block. - * @returns A Promise that resolves to the retrieved block. - */ - @Get(':id') - async findOne( - @Param('id') id: string, - @Query(PopulatePipe) - populate: string[], - ): Promise { - const record = this.canPopulate(populate) - ? await this.blockService.findOneAndPopulate(id) - : await this.blockService.findOne(id); - if (!record) { - this.logger.warn(`Unable to find Block by id ${id}`); - throw new NotFoundException(`Block with ID ${id} not found`); - } - - return record; - } - - /** - * Creates a new block. - * - * @param block - The data of the block to be created. - * @returns A Promise that resolves to the created block. - */ - @Post() - async create(@Body() block: BlockCreateDto): Promise { - // TODO: typeorm fk constraint doesn't support nested objects, we need to refactor it to support nested objects - if (block.options?.assignTo) { - const user = await this.userService.findOne(block.options.assignTo); - if (!user) { - throw new BadRequestException( - `options.assignTo with ID ${block.options.assignTo} not found`, - ); - } - } - - return await this.blockService.create(block); - } - - /** - * Updates multiple blocks by their IDs. - * @param ids - IDs of blocks to be updated. - * @param payload - The data to update blocks with. - * @returns A Promise that resolves to the updates if successful. - */ - @Patch('bulk') - async updateMany(@Body() body: { ids: string[]; payload: BlockUpdateDto }) { - if (!body.ids || body.ids.length === 0) { - throw new BadRequestException('No IDs provided to perform the update'); - } - const updates = await this.blockService.updateMany( - { where: { id: In(body.ids) } }, - body.payload, - ); - - return updates; - } - - /** - * Updates a specific block by ID. - * - * @param id - The ID of the block to update. - * @param blockUpdate - The data to update the block with. - * @returns A Promise that resolves to the updated block if successful. - */ - @Patch(':id') - async updateOne( - @Param('id') id: string, - @Body() blockUpdate: BlockUpdateDto, - ): Promise { - return await this.blockService.updateOne(id, blockUpdate); - } - - /** - * Deletes a specific block by ID. - * - * @param id - The ID of the block to delete. - * @returns A Promise that resolves to the deletion result. - */ - @Delete(':id') - @HttpCode(204) - async deleteOne(@Param('id') id: string): Promise { - const result = await this.blockService.deleteOne(id); - if (result.deletedCount === 0) { - this.logger.warn(`Unable to delete Block by id ${id}`); - throw new NotFoundException(`Block with ID ${id} not found`); - } - - return result; - } - - /** - * Deletes multiple blocks by their IDs. - * @param ids - IDs of blocks to be deleted. - * @returns A Promise that resolves to the deletion result. - */ - @Delete('') - @HttpCode(204) - async deleteMany(@Body('ids') ids?: string[]): Promise { - if (!ids?.length) { - throw new BadRequestException('No IDs provided for deletion.'); - } - const deleteResult = await this.blockService.deleteMany({ - where: { id: In(ids) }, - }); - - if (deleteResult.deletedCount === 0) { - this.logger.warn(`Unable to delete blocks with provided IDs: ${ids}`); - throw new NotFoundException('Blocks with provided IDs not found'); - } - - this.logger.log(`Successfully deleted blocks with IDs: ${ids}`); - - return deleteResult; - } -} diff --git a/packages/api/src/chat/controllers/category.contoller.spec.ts b/packages/api/src/chat/controllers/category.contoller.spec.ts deleted file mode 100644 index 02cb2e7ec..000000000 --- a/packages/api/src/chat/controllers/category.contoller.spec.ts +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { randomUUID } from 'crypto'; - -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { TestingModule } from '@nestjs/testing'; -import { In } from 'typeorm'; - -import { - categoryFixtures, - installCategoryFixturesTypeOrm, -} from '@/utils/test/fixtures/category'; -import { closeTypeOrmConnections } from '@/utils/test/test'; -import { buildTestingMocks } from '@/utils/test/utils'; - -import { - Category, - CategoryCreateDto, - CategoryUpdateDto, -} from '../dto/category.dto'; -import { CategoryService } from '../services/category.service'; - -import { CategoryController } from './category.controller'; - -describe('CategoryController (TypeORM)', () => { - let module: TestingModule; - let categoryController: CategoryController; - let categoryService: CategoryService; - - beforeAll(async () => { - const testing = await buildTestingMocks({ - autoInjectFrom: ['controllers'], - controllers: [CategoryController], - typeorm: { - fixtures: installCategoryFixturesTypeOrm, - }, - }); - - module = testing.module; - - [categoryController, categoryService] = await testing.getMocks([ - CategoryController, - CategoryService, - ]); - }); - - afterEach(jest.clearAllMocks); - - afterAll(async () => { - if (module) { - await module.close(); - } - await closeTypeOrmConnections(); - }); - - describe('filterCount', () => { - it('should count categories', async () => { - const expectedCount = await categoryService.count({}); - const countSpy = jest.spyOn(categoryService, 'count'); - const result = await categoryController.filterCount(); - - expect(countSpy).toHaveBeenCalledWith({}); - expect(result).toEqual({ count: expectedCount }); - }); - }); - - describe('findPage', () => { - it('should find categories', async () => { - const expected = await categoryService.find({}); - const findSpy = jest.spyOn(categoryService, 'find'); - const result = await categoryController.findPage({}); - - expect(findSpy).toHaveBeenCalledWith({}); - expect(result).toEqualPayload(expected); - }); - }); - - describe('findOne', () => { - it('should return the existing category', async () => { - const target = await categoryService.findOne({ - where: { label: categoryFixtures[0].label }, - }); - expect(target).toBeDefined(); - - const findOneSpy = jest.spyOn(categoryService, 'findOne'); - const result = await categoryController.findOne(target!.id); - - expect(findOneSpy).toHaveBeenCalledWith(target!.id); - expect(result).toEqualPayload(categoryFixtures[0]); - }); - - it('should throw a NotFoundException when category does not exist', async () => { - const id = randomUUID(); - const findOneSpy = jest - .spyOn(categoryService, 'findOne') - .mockResolvedValueOnce(null); - - await expect(categoryController.findOne(id)).rejects.toThrow( - new NotFoundException(`Category with ID ${id} not found`), - ); - expect(findOneSpy).toHaveBeenCalledWith(id); - }); - }); - - describe('create', () => { - it('should create a category', async () => { - const payload: CategoryCreateDto = { - label: `category-${Math.random().toString(36).slice(2, 10)}`, - builtin: false, - zoom: 150, - offset: [10, 20], - }; - const createSpy = jest.spyOn(categoryService, 'create'); - const result = await categoryController.create(payload); - - expect(createSpy).toHaveBeenCalledWith(payload); - expect(result).toEqualPayload(payload); - - await categoryService.deleteOne(result.id); - }); - }); - - describe('updateOne', () => { - it('should update an existing category', async () => { - const created = await categoryService.create({ - label: `category-${Math.random().toString(36).slice(2, 10)}`, - builtin: false, - }); - const updates: CategoryUpdateDto = { - zoom: 80, - offset: [5, 5], - }; - const updateSpy = jest.spyOn(categoryService, 'updateOne'); - const result = await categoryController.updateOne(created.id, updates); - - expect(updateSpy).toHaveBeenCalledWith(created.id, updates); - expect(result.id).toBe(created.id); - expect(result.zoom).toBe(updates.zoom); - expect(result.offset).toEqual(updates.offset); - - await categoryService.deleteOne(result.id); - }); - }); - - describe('deleteOne', () => { - it('should delete a category by id', async () => { - const deletable = await categoryService.create({ - label: `category-${Math.random().toString(36).slice(2, 10)}`, - builtin: false, - }); - const deleteSpy = jest.spyOn(categoryService, 'deleteOne'); - const result = await categoryController.deleteOne(deletable.id); - - expect(deleteSpy).toHaveBeenCalledWith(deletable.id); - expect(result).toEqualPayload({ acknowledged: true, deletedCount: 1 }); - - const lookup = await categoryService.findOne(deletable.id); - expect(lookup).toBeNull(); - }); - - it('should throw a NotFoundException when deletion result is empty', async () => { - const id = randomUUID(); - const deleteSpy = jest - .spyOn(categoryService, 'deleteOne') - .mockResolvedValueOnce({ acknowledged: true, deletedCount: 0 }); - - await expect(categoryController.deleteOne(id)).rejects.toThrow( - new NotFoundException(`Category with ID ${id} not found`), - ); - expect(deleteSpy).toHaveBeenCalledWith(id); - }); - }); - - describe('deleteMany', () => { - it('should delete multiple categories by ids', async () => { - const createdCategories: Category[] = await categoryService.createMany([ - { label: `category-${Math.random().toString(36).slice(2, 10)}` }, - { label: `category-${Math.random().toString(36).slice(2, 10)}` }, - ]); - const ids = createdCategories.map(({ id }) => id); - const result = await categoryController.deleteMany(ids); - - expect(result).toEqualPayload({ - acknowledged: true, - deletedCount: ids.length, - }); - - const remaining = await categoryService.find({ - where: { id: In(ids) }, - }); - expect(remaining).toHaveLength(0); - }); - - it('should throw a NotFoundException when provided IDs do not exist', async () => { - const ids = [randomUUID(), randomUUID()]; - - await expect(categoryController.deleteMany(ids)).rejects.toThrow( - new NotFoundException('Categories with provided IDs not found'), - ); - }); - - it('should throw a BadRequestException when no ids are provided', async () => { - await expect(categoryController.deleteMany([])).rejects.toThrow( - new BadRequestException('No IDs provided for deletion.'), - ); - }); - }); -}); diff --git a/packages/api/src/chat/controllers/category.controller.ts b/packages/api/src/chat/controllers/category.controller.ts deleted file mode 100644 index c2cb7fae4..000000000 --- a/packages/api/src/chat/controllers/category.controller.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { - BadRequestException, - Body, - Controller, - Delete, - Get, - HttpCode, - NotFoundException, - Param, - Patch, - Post, - Query, -} from '@nestjs/common'; -import { FindManyOptions, In } from 'typeorm'; - -import { BaseOrmController } from '@/utils/generics/base-orm.controller'; -import { DeleteResult } from '@/utils/generics/base-orm.repository'; -import { TypeOrmSearchFilterPipe } from '@/utils/pipes/typeorm-search-filter.pipe'; - -import { - Category, - CategoryCreateDto, - CategoryDtoConfig, - CategoryTransformerDto, - CategoryUpdateDto, -} from '../dto/category.dto'; -import { CategoryOrmEntity } from '../entities/category.entity'; -import { CategoryService } from '../services/category.service'; - -@Controller('category') -export class CategoryController extends BaseOrmController< - CategoryOrmEntity, - CategoryTransformerDto, - CategoryDtoConfig -> { - constructor(private readonly categoryService: CategoryService) { - super(categoryService); - } - - /** - * Retrieves a paginated list of categories based on provided filters and pagination settings. - * @param options - Combined filters, pagination, and sorting for the query. - * @returns A Promise that resolves to a paginated list of categories. - */ - @Get() - async findPage( - @Query( - new TypeOrmSearchFilterPipe({ - allowedFields: ['label', 'builtin'], - defaultSort: ['createdAt', 'desc'], - }), - ) - options: FindManyOptions, - ) { - return await this.categoryService.find(options ?? {}); - } - - /** - * Counts the filtered number of categories. - * @returns A promise that resolves to an object representing the filtered number of categories. - */ - @Get('count') - async filterCount( - @Query( - new TypeOrmSearchFilterPipe({ - allowedFields: ['label', 'builtin'], - }), - ) - options?: FindManyOptions, - ) { - return await this.count(options ?? {}); - } - - /** - * Finds a category by its ID. - * @param id - The ID of the category to find. - * @returns A Promise that resolves to the found category. - */ - @Get(':id') - async findOne(@Param('id') id: string): Promise { - const record = await this.categoryService.findOne(id); - if (!record) { - this.logger.warn(`Unable to find Category by id ${id}`); - throw new NotFoundException(`Category with ID ${id} not found`); - } - - return record; - } - - /** - * Creates a new category. - * @param category - The data of the category to be created. - * @returns A Promise that resolves to the created category. - */ - @Post() - async create(@Body() category: CategoryCreateDto): Promise { - return await this.categoryService.create(category); - } - - /** - * Updates an existing category. - * @param id - The ID of the category to be updated. - * @param categoryUpdate - The updated data for the category. - * @returns A Promise that resolves to the updated category. - */ - @Patch(':id') - async updateOne( - @Param('id') id: string, - @Body() categoryUpdate: CategoryUpdateDto, - ): Promise { - return await this.categoryService.updateOne(id, categoryUpdate); - } - - /** - * Deletes a category by its ID. - * @param id - The ID of the category to be deleted. - * @returns A Promise that resolves to the deletion result. - */ - @Delete(':id') - @HttpCode(204) - async deleteOne(@Param('id') id: string): Promise { - const result = await this.categoryService.deleteOne(id); - if (result.deletedCount === 0) { - this.logger.warn(`Unable to delete Category by id ${id}`); - throw new NotFoundException(`Category with ID ${id} not found`); - } - - return result; - } - - /** - * Deletes multiple categories by their IDs. - * @param ids - IDs of categories to be deleted. - * @returns A Promise that resolves to the deletion result. - */ - @Delete('') - @HttpCode(204) - async deleteMany(@Body('ids') ids?: string[]): Promise { - if (!ids?.length) { - throw new BadRequestException('No IDs provided for deletion.'); - } - const deleteResult = await this.categoryService.deleteMany({ - where: { id: In(ids) }, - }); - - if (deleteResult.deletedCount === 0) { - this.logger.warn(`Unable to delete categories with provided IDs: ${ids}`); - throw new NotFoundException('Categories with provided IDs not found'); - } - - this.logger.log(`Successfully deleted categories with IDs: ${ids}`); - - return deleteResult; - } -} diff --git a/packages/api/src/chat/dto/block.dto.ts b/packages/api/src/chat/dto/block.dto.ts deleted file mode 100644 index 094ee78c6..000000000 --- a/packages/api/src/chat/dto/block.dto.ts +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { - ApiProperty, - ApiPropertyOptional, - OmitType, - PartialType, -} from '@nestjs/swagger'; -import { Exclude, Expose, Transform, Type } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsInt, - IsNotEmpty, - IsObject, - IsOptional, - IsString, - Max, - Min, -} from 'class-validator'; -import { z } from 'zod'; - -import { IsUUIDv4 } from '@/utils/decorators/is-uuid.decorator'; -import { Validate } from '@/utils/decorators/validate.decorator'; -import { SanitizeQueryPipe } from '@/utils/pipes/sanitize-query.pipe'; -import { - BaseStub, - DtoActionConfig, - DtoTransformerConfig, -} from '@/utils/types/dto.types'; - -import { DEFAULT_BLOCK_SEARCH_LIMIT } from '../constants/block'; -import { CaptureVar, captureVarSchema } from '../types/capture-var'; -import { BlockMessage, blockMessageObjectSchema } from '../types/message'; -import { BlockOptions, BlockOptionsSchema } from '../types/options'; -import { Pattern, patternSchema } from '../types/pattern'; -import { Position, positionSchema } from '../types/position'; - -import { Category } from './category.dto'; -import { Label } from './label.dto'; - -@Exclude() -export class BlockStub extends BaseStub { - @Expose() - name!: string; - - @Expose() - patterns!: Pattern[]; - - @Expose() - outcomes!: string[]; - - @Expose() - trigger_channels!: string[]; - - @Expose() - options: BlockOptions; - - @Expose() - message!: BlockMessage; - - @Expose() - starts_conversation!: boolean; - - @Expose() - capture_vars!: CaptureVar[]; - - @Expose() - @Transform(({ value }) => (value == null ? undefined : value)) - position?: Position | null; - - @Expose() - builtin!: boolean; -} - -@Exclude() -export class Block extends BlockStub { - @Expose({ name: 'triggerLabelIds' }) - trigger_labels!: string[]; - - @Expose({ name: 'assignLabelIds' }) - assign_labels!: string[]; - - @Expose({ name: 'nextBlockIds' }) - nextBlocks!: string[]; - - @Expose({ name: 'attachedBlockId' }) - @Transform(({ value }) => (value == null ? undefined : value)) - attachedBlock?: string | null; - - @Expose({ name: 'categoryId' }) - @Transform(({ value }) => (value == null ? undefined : value)) - category?: string | null; - - @Exclude() - previousBlocks?: never; - - @Exclude() - attachedToBlock?: never; -} - -@Exclude() -export class BlockFull extends BlockStub { - @Expose() - @Type(() => Label) - trigger_labels!: Label[]; - - @Expose() - @Type(() => Label) - assign_labels!: Label[]; - - @Expose() - @Type(() => Block) - nextBlocks!: Block[]; - - @Expose() - @Type(() => Block) - attachedBlock?: Block | null; - - @Expose() - @Type(() => Category) - category?: Category | null; - - @Expose() - @Type(() => Block) - previousBlocks?: Block[]; - - @Expose() - @Type(() => Block) - attachedToBlock?: Block | null; -} - -@Exclude() -export class SearchRankedBlock extends Block { - @Expose() - score!: number; -} - -export class BlockCreateDto { - @ApiProperty({ description: 'Block name', type: String }) - @IsNotEmpty() - @IsString() - name: string; - - @ApiPropertyOptional({ description: 'Block patterns', type: Array }) - @IsOptional() - @Validate(z.array(patternSchema)) - patterns?: Pattern[] = []; - - @ApiPropertyOptional({ - description: "Block's outcomes", - type: Array, - }) - @IsOptional() - @IsArray({ message: 'Outcomes are invalid' }) - outcomes?: string[] = []; - - @ApiPropertyOptional({ description: 'Block trigger labels', type: Array }) - @IsOptional() - @IsArray() - @IsUUIDv4({ each: true, message: 'Trigger label must be a valid UUID' }) - trigger_labels?: string[] = []; - - @ApiPropertyOptional({ description: 'Block assign labels', type: Array }) - @IsOptional() - @IsArray() - @IsUUIDv4({ each: true, message: 'Assign label must be a valid UUID' }) - assign_labels?: string[] = []; - - @ApiPropertyOptional({ description: 'Block trigger channels', type: Array }) - @IsOptional() - @IsArray() - trigger_channels?: string[] = []; - - @ApiPropertyOptional({ description: 'Block options', type: Object }) - @IsOptional() - @IsObject() - @Validate(BlockOptionsSchema) - options?: BlockOptions; - - @ApiProperty({ description: 'Block message', type: Object }) - @IsNotEmpty() - @Validate(blockMessageObjectSchema) - message: BlockMessage; - - @ApiPropertyOptional({ description: 'next blocks', type: Array }) - @IsOptional() - @IsArray() - @IsUUIDv4({ each: true, message: 'Next block must be a valid UUID' }) - nextBlocks?: string[]; - - @ApiPropertyOptional({ description: 'attached blocks', type: String }) - @IsOptional() - @IsString() - @IsUUIDv4({ - message: 'Attached block must be a valid UUID', - }) - attachedBlock?: string | null; - - @ApiProperty({ description: 'Block category', type: String }) - @IsNotEmpty() - @IsString() - @IsUUIDv4({ message: 'Category must be a valid UUID' }) - category: string | null; - - @ApiPropertyOptional({ - description: 'Block has started conversation', - type: Boolean, - }) - @IsBoolean() - @IsOptional() - starts_conversation?: boolean; - - @ApiPropertyOptional({ - description: 'Block capture vars', - type: Array, - }) - @IsOptional() - @Validate(z.array(captureVarSchema)) - capture_vars?: CaptureVar[]; - - @ApiProperty({ - description: 'Block position', - type: Object, - }) - @IsNotEmpty() - @Validate(positionSchema) - position: Position; -} - -export class BlockUpdateDto extends PartialType( - OmitType(BlockCreateDto, [ - 'patterns', - 'outcomes', - 'trigger_labels', - 'assign_labels', - 'trigger_channels', - ]), -) { - @ApiPropertyOptional({ description: 'Block patterns', type: Array }) - @IsOptional() - @Validate(z.array(patternSchema)) - patterns?: Pattern[]; - - @ApiPropertyOptional({ - description: "Block's outcomes", - type: Array, - }) - @IsOptional() - @IsArray({ message: 'Outcomes are invalid' }) - outcomes?: string[]; - - @ApiPropertyOptional({ description: 'Block trigger labels', type: Array }) - @IsOptional() - @IsArray() - @IsUUIDv4({ each: true, message: 'Trigger label must be a valid UUID' }) - trigger_labels?: string[]; - - @ApiPropertyOptional({ description: 'Block assign labels', type: Array }) - @IsOptional() - @IsArray() - @IsUUIDv4({ each: true, message: 'Assign label must be a valid UUID' }) - assign_labels?: string[]; - - @ApiPropertyOptional({ description: 'Block trigger channels', type: Array }) - @IsArray() - @IsOptional() - trigger_channels?: string[]; -} - -export class BlockSearchQueryDto { - @ApiPropertyOptional({ - description: 'Search term to filter blocks', - type: String, - }) - @IsOptional() - @IsString() - @Transform(({ value }) => SanitizeQueryPipe.sanitize(value)) - q?: string; - - @ApiPropertyOptional({ - description: `Maximum number of results to return (default: ${DEFAULT_BLOCK_SEARCH_LIMIT}, max: ${DEFAULT_BLOCK_SEARCH_LIMIT})`, - type: Number, - default: DEFAULT_BLOCK_SEARCH_LIMIT, - maximum: DEFAULT_BLOCK_SEARCH_LIMIT, - minimum: 1, - }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(DEFAULT_BLOCK_SEARCH_LIMIT) - limit: number = DEFAULT_BLOCK_SEARCH_LIMIT; - - @ApiPropertyOptional({ - description: 'Category to filter search results', - type: String, - }) - @IsOptional() - @IsNotEmpty() - @IsString() - @IsUUIDv4({ message: 'Category must be a valid UUID' }) - category?: string; -} - -export type BlockTransformerDto = DtoTransformerConfig<{ - PlainCls: typeof Block; - FullCls: typeof BlockFull; -}>; - -export type BlockDtoConfig = DtoActionConfig<{ - create: BlockCreateDto; - update: BlockUpdateDto; -}>; - -export type BlockDto = BlockDtoConfig; diff --git a/packages/api/src/chat/dto/category.dto.ts b/packages/api/src/chat/dto/category.dto.ts deleted file mode 100644 index ed9f7c0ac..000000000 --- a/packages/api/src/chat/dto/category.dto.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { Exclude, Expose, Type } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, -} from 'class-validator'; - -import { - BaseStub, - DtoActionConfig, - DtoTransformerConfig, -} from '@/utils/types/dto.types'; - -import { Block } from './block.dto'; - -@Exclude() -export class CategoryStub extends BaseStub { - @Expose() - label!: string; - - @Expose() - builtin!: boolean; - - @Expose() - zoom!: number; - - @Expose() - offset!: [number, number]; -} - -@Exclude() -export class Category extends CategoryStub { - @Exclude() - blocks?: never; -} - -@Exclude() -export class CategoryFull extends CategoryStub { - @Expose() - @Type(() => Block) - blocks?: Block[]; -} - -export class CategoryCreateDto { - @ApiProperty({ description: 'Category label', type: String }) - @IsNotEmpty() - @IsString() - label: string; - - @ApiPropertyOptional({ description: 'Category is builtin', type: Boolean }) - @IsOptional() - @IsBoolean() - builtin?: boolean; - - @ApiPropertyOptional({ description: 'Zoom', type: Number }) - @IsOptional() - @IsNumber() - zoom?: number; - - @ApiPropertyOptional({ description: 'Offset', type: Array }) - @IsOptional() - @IsArray() - offset?: [number, number]; -} - -export class CategoryUpdateDto extends PartialType(CategoryCreateDto) {} - -export type CategoryTransformerDto = DtoTransformerConfig<{ - PlainCls: typeof Category; - FullCls: typeof CategoryFull; -}>; - -export type CategoryDtoConfig = DtoActionConfig<{ - create: CategoryCreateDto; - update: CategoryUpdateDto; -}>; - -export type CategoryDto = CategoryDtoConfig; diff --git a/packages/api/src/chat/dto/conversation.dto.ts b/packages/api/src/chat/dto/conversation.dto.ts deleted file mode 100644 index 9a35e8d92..000000000 --- a/packages/api/src/chat/dto/conversation.dto.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { Exclude, Expose, Transform, Type } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsNotEmpty, - IsObject, - IsOptional, - IsString, -} from 'class-validator'; - -import { IsUUIDv4 } from '@/utils/decorators/is-uuid.decorator'; -import { - BaseStub, - DtoActionConfig, - DtoTransformerConfig, -} from '@/utils/types/dto.types'; - -import { Context } from './../types/context'; -import { Block } from './block.dto'; -import { Subscriber } from './subscriber.dto'; - -@Exclude() -export class ConversationStub extends BaseStub { - @Expose() - active!: boolean; - - @Expose() - context!: Context; -} - -@Exclude() -export class Conversation extends ConversationStub { - @Expose({ name: 'senderId' }) - sender!: string; - - @Expose({ name: 'currentBlockId' }) - @Transform(({ value }) => (value == null ? undefined : value)) - current?: string | null; - - @Expose({ name: 'nextBlockIds' }) - next!: string[]; -} - -@Exclude() -export class ConversationFull extends ConversationStub { - @Expose() - @Type(() => Subscriber) - sender!: Subscriber; - - @Expose() - @Type(() => Block) - current: Block; - - @Expose() - @Type(() => Block) - next!: Block[]; -} - -export class ConversationCreateDto { - @ApiProperty({ description: 'Conversation sender', type: String }) - @IsNotEmpty() - @IsString() - @IsUUIDv4({ - message: 'Sender must be a valid UUID', - }) - sender: string; - - @ApiPropertyOptional({ description: 'Conversation is active', type: Boolean }) - @IsBoolean() - @IsOptional() - active?: boolean; - - @ApiPropertyOptional({ description: 'Conversation context', type: Object }) - @IsOptional() - @IsObject() - context?: Context; - - @ApiProperty({ description: 'Current conversation', type: String }) - @IsOptional() - @IsString() - @IsUUIDv4({ - message: 'Current must be a valid UUID', - }) - current?: string | null; - - @ApiProperty({ description: 'next conversation', type: Array }) - @IsOptional() - @IsArray() - @IsUUIDv4({ - each: true, - message: 'next must be a valid UUID', - }) - next?: string[]; -} - -export type ConversationTransformerDto = DtoTransformerConfig<{ - PlainCls: typeof Conversation; - FullCls: typeof ConversationFull; -}>; - -export class ConversationUpdateDto extends PartialType(ConversationCreateDto) {} - -export type ConversationDtoConfig = DtoActionConfig<{ - create: ConversationCreateDto; - update: ConversationUpdateDto; -}>; - -export type ConversationDto = ConversationDtoConfig; diff --git a/packages/api/src/chat/entities/block.entity.ts b/packages/api/src/chat/entities/block.entity.ts deleted file mode 100644 index be11c297b..000000000 --- a/packages/api/src/chat/entities/block.entity.ts +++ /dev/null @@ -1,367 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { ConflictException } from '@nestjs/common'; -import { - BeforeRemove, - BeforeUpdate, - Column, - Entity, - Index, - JoinColumn, - JoinTable, - ManyToMany, - ManyToOne, - OneToOne, - RelationId, -} from 'typeorm'; - -import { JsonColumn } from '@/database/decorators/json-column.decorator'; -import { BaseOrmEntity } from '@/database/entities/base.entity'; -import { SettingOrmEntity } from '@/setting/entities/setting.entity'; -import { AsRelation } from '@/utils/decorators/relation-ref.decorator'; - -import { CaptureVar } from '../types/capture-var'; -import { BlockMessage } from '../types/message'; -import { BlockOptions } from '../types/options'; -import { Pattern } from '../types/pattern'; -import { Position } from '../types/position'; - -import { CategoryOrmEntity } from './category.entity'; -import { ConversationOrmEntity } from './conversation.entity'; -import { LabelOrmEntity } from './label.entity'; - -@Entity({ name: 'blocks' }) -@Index(['name']) -export class BlockOrmEntity extends BaseOrmEntity { - @Column() - name!: string; - - @JsonColumn({ default: '[]' }) - patterns: Pattern[]; - - @JsonColumn({ default: '[]' }) - outcomes: string[]; - - @ManyToMany(() => LabelOrmEntity, (label) => label.triggerBlocks, { - cascade: false, - }) - @JoinTable({ - name: 'block_trigger_labels', - joinColumn: { name: 'block_id' }, - inverseJoinColumn: { name: 'label_id' }, - }) - @AsRelation({ allowArray: true }) - trigger_labels: LabelOrmEntity[]; - - @RelationId((block: BlockOrmEntity) => block.trigger_labels) - private readonly triggerLabelIds!: string[]; - - @ManyToMany(() => LabelOrmEntity, (label) => label.assignedBlocks, { - cascade: false, - }) - @JoinTable({ - name: 'block_assign_labels', - joinColumn: { name: 'block_id' }, - inverseJoinColumn: { name: 'label_id' }, - }) - @AsRelation({ allowArray: true }) - assign_labels: LabelOrmEntity[]; - - @RelationId((block: BlockOrmEntity) => block.assign_labels) - private readonly assignLabelIds!: string[]; - - @JsonColumn({ default: '[]' }) - trigger_channels: string[]; - - @JsonColumn({ default: '{}' }) - options: BlockOptions; - - @JsonColumn() - message!: BlockMessage; - - @ManyToMany(() => BlockOrmEntity, (block) => block.previousBlocks, { - cascade: false, - }) - @JoinTable({ - name: 'block_next_blocks', - joinColumn: { name: 'block_id' }, - inverseJoinColumn: { name: 'next_block_id' }, - }) - @AsRelation({ allowArray: true }) - nextBlocks: BlockOrmEntity[]; - - @RelationId((block: BlockOrmEntity) => block.nextBlocks) - private readonly nextBlockIds!: string[]; - - @ManyToMany(() => BlockOrmEntity, (block) => block.nextBlocks) - @AsRelation({ allowArray: true }) - previousBlocks?: BlockOrmEntity[]; - - @OneToOne(() => BlockOrmEntity, (block) => block.attachedToBlock, { - nullable: true, - onDelete: 'SET NULL', - }) - @JoinColumn({ name: 'attached_block_id' }) - @AsRelation() - attachedBlock?: BlockOrmEntity | null; - - @RelationId((block: BlockOrmEntity) => block.attachedBlock) - private readonly attachedBlockId?: string | null; - - @OneToOne(() => BlockOrmEntity, (block) => block.attachedBlock) - @AsRelation() - attachedToBlock?: BlockOrmEntity | null; - - @ManyToOne(() => CategoryOrmEntity, (category) => category.blocks, { - nullable: true, - onDelete: 'SET NULL', - }) - @JoinColumn({ name: 'category_id' }) - @AsRelation() - category: CategoryOrmEntity | null; - - @RelationId((block: BlockOrmEntity) => block.category) - private readonly categoryId: string | null; - - @Column({ default: false }) - starts_conversation!: boolean; - - @JsonColumn({ default: '[]' }) - capture_vars: CaptureVar[]; - - @JsonColumn({ nullable: true }) - position?: Position | null; - - @Column({ default: false }) - builtin!: boolean; - - @BeforeUpdate() - protected async enforceCategoryConsistency(): Promise { - if (!this.id) { - return; - } - - const repository = - BlockOrmEntity.getEntityManager().getRepository(BlockOrmEntity); - const persistedBlock = await repository.findOne({ - where: { id: this.id }, - relations: [ - 'category', - 'nextBlocks', - 'nextBlocks.category', - 'previousBlocks', - 'previousBlocks.category', - 'attachedBlock', - 'attachedBlock.category', - 'attachedToBlock', - 'attachedToBlock.category', - ], - }); - - if (!persistedBlock) { - return; - } - - const previousCategoryId = persistedBlock.category?.id ?? null; - const categoryProvided = Object.prototype.hasOwnProperty.call( - this, - 'category', - ); - const nextCategoryId = categoryProvided - ? this.category - ? this.category.id - : null - : (this.categoryId ?? previousCategoryId); - - if (previousCategoryId === nextCategoryId) { - return; - } - - const nextBlocksToDetach = (persistedBlock.nextBlocks ?? []).filter( - (nextBlock) => (nextBlock.category?.id ?? null) !== nextCategoryId, - ); - - if (nextBlocksToDetach.length) { - await repository - .createQueryBuilder() - .relation(BlockOrmEntity, 'nextBlocks') - .of(this.id) - .remove(nextBlocksToDetach.map((block) => block.id)); - - if (this.nextBlocks) { - this.nextBlocks = this.nextBlocks.filter( - (block) => - !nextBlocksToDetach.some((toDetach) => toDetach.id === block.id), - ); - } - } - - const previousBlocksToDetach = (persistedBlock.previousBlocks ?? []).filter( - (prevBlock) => (prevBlock.category?.id ?? null) !== nextCategoryId, - ); - - if (previousBlocksToDetach.length) { - await repository - .createQueryBuilder() - .relation(BlockOrmEntity, 'nextBlocks') - .of(previousBlocksToDetach.map((block) => block.id)) - .remove(this.id); - } - - const attachedBlock = persistedBlock.attachedBlock; - if ( - attachedBlock && - (attachedBlock.category?.id ?? null) !== nextCategoryId - ) { - await repository - .createQueryBuilder() - .relation(BlockOrmEntity, 'attachedBlock') - .of(this.id) - .set(null); - - if (this.attachedBlock && this.attachedBlock.id === attachedBlock.id) { - this.attachedBlock = null; - } - } - - const attachedToBlock = persistedBlock.attachedToBlock; - if ( - attachedToBlock && - (attachedToBlock.category?.id ?? null) !== nextCategoryId - ) { - await repository - .createQueryBuilder() - .relation(BlockOrmEntity, 'attachedBlock') - .of(attachedToBlock.id) - .set(null); - } - } - - @BeforeRemove() - protected async ensureDeletable(): Promise { - if (!this.id) { - return; - } - - await this.removeReferencesToBlock(); - await this.ensureNotUsedInActiveConversations(); - await this.ensureNotConfiguredAsGlobalFallback(); - } - - async removeReferencesToBlock(): Promise { - await this.removeAttachedBlockReferences(); - await this.removeNextBlockReferences(); - } - - private async removeAttachedBlockReferences(): Promise { - const repository = - BlockOrmEntity.getEntityManager().getRepository(BlockOrmEntity); - const blocks = await repository.find({ - where: { - attachedBlock: { - id: this.id, - }, - }, - relations: ['attachedBlock'], - }); - - if (!blocks.length) { - return; - } - - for (const block of blocks) { - block.attachedBlock = null; - } - - await repository.save(blocks); - } - - private async removeNextBlockReferences(): Promise { - const repository = - BlockOrmEntity.getEntityManager().getRepository(BlockOrmEntity); - const blocks = await repository.find({ - where: { - nextBlocks: { - id: this.id, - }, - }, - relations: ['nextBlocks'], - }); - - if (!blocks.length) { - return; - } - - for (const block of blocks) { - block.nextBlocks = block.nextBlocks.filter( - (nextBlock) => this.id !== nextBlock.id, - ); - } - - await repository.save(blocks); - } - - private async ensureNotUsedInActiveConversations(): Promise { - const conversationRepository = - BlockOrmEntity.getEntityManager().getRepository(ConversationOrmEntity); - const inUse = await conversationRepository.find({ - where: [ - { active: true, current: { id: this.id } }, - { active: true, next: { id: this.id } }, - ], - relations: ['current', 'next'], - take: 1, - }); - - if (inUse.length) { - throw new ConflictException( - 'Cannot delete block: it is currently used by an active conversation.', - ); - } - } - - private async ensureNotConfiguredAsGlobalFallback(): Promise { - const manager = BlockOrmEntity.getEntityManager(); - const settingsRepository = manager.getRepository(SettingOrmEntity); - const [fallbackSetting, globalFallbackSetting] = await Promise.all([ - settingsRepository.findOne({ - select: ['value'], - where: { - group: 'chatbot_settings', - label: 'fallback_block', - }, - }), - settingsRepository.findOne({ - select: ['value'], - where: { - group: 'chatbot_settings', - label: 'global_fallback', - }, - }), - ]); - const fallbackValue = fallbackSetting?.value; - const fallbackBlockId = - typeof fallbackValue === 'string' - ? fallbackValue - : fallbackValue && typeof fallbackValue === 'object' - ? String((fallbackValue as Record).id ?? '') || null - : null; - const fallbackEnabledValue = globalFallbackSetting?.value; - const isGlobalFallbackEnabled = - fallbackEnabledValue === true || fallbackEnabledValue === 'true'; - - if ( - fallbackBlockId && - fallbackBlockId === this.id && - isGlobalFallbackEnabled - ) { - throw new ConflictException( - 'Cannot delete block: it is configured as the global fallback block in settings.', - ); - } - } -} diff --git a/packages/api/src/chat/entities/category.entity.ts b/packages/api/src/chat/entities/category.entity.ts deleted file mode 100644 index 3a68d50c8..000000000 --- a/packages/api/src/chat/entities/category.entity.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { ForbiddenException } from '@nestjs/common'; -import { BeforeRemove, Column, Entity, Index, OneToMany } from 'typeorm'; - -import { JsonColumn } from '@/database/decorators/json-column.decorator'; -import { BaseOrmEntity } from '@/database/entities/base.entity'; - -import { BlockOrmEntity } from './block.entity'; - -@Entity({ name: 'categories' }) -@Index(['label'], { unique: true }) -export class CategoryOrmEntity extends BaseOrmEntity { - @Column() - label!: string; - - @Column({ default: false }) - builtin!: boolean; - - @Column({ type: 'integer', default: 100 }) - zoom!: number; - - @JsonColumn({ default: '[0, 0]' }) - offset: [number, number]; - - @OneToMany(() => BlockOrmEntity, (block) => block.category) - blocks?: BlockOrmEntity[]; - - @BeforeRemove() - async ensureNoBlocksBeforeDelete(): Promise { - const message = `Category ${this.label} has at least one associated block`; - - if (Array.isArray(this.blocks) && this.blocks.length > 0) { - throw new ForbiddenException(message); - } - - const manager = CategoryOrmEntity.getEntityManager(); - const blockCount = await manager.getRepository(BlockOrmEntity).count({ - where: { - category: { - id: this.id, - }, - }, - }); - - if (blockCount > 0) { - throw new ForbiddenException(message); - } - } -} diff --git a/packages/api/src/chat/entities/context-var.entity.ts b/packages/api/src/chat/entities/context-var.entity.ts index 328f1b66b..24c7712cd 100644 --- a/packages/api/src/chat/entities/context-var.entity.ts +++ b/packages/api/src/chat/entities/context-var.entity.ts @@ -4,13 +4,10 @@ * Full terms: see LICENSE.md. */ -import { ForbiddenException } from '@nestjs/common'; -import { BeforeRemove, Column, Entity, Index } from 'typeorm'; +import { Column, Entity, Index } from 'typeorm'; import { BaseOrmEntity } from '@/database/entities/base.entity'; -import { BlockOrmEntity } from './block.entity'; - @Entity({ name: 'context_vars' }) @Index(['label'], { unique: true }) @Index(['name'], { unique: true }) @@ -23,49 +20,4 @@ export class ContextVarOrmEntity extends BaseOrmEntity { @Column({ default: false }) permanent!: boolean; - - @BeforeRemove() - protected async ensureNotInUse(): Promise { - const manager = ContextVarOrmEntity.getEntityManager(); - const databaseType = manager.connection.options.type; - const blocksQuery = manager - .getRepository(BlockOrmEntity) - .createQueryBuilder('block') - .select(['block.name']); - - if (databaseType === 'sqlite' || databaseType === 'better-sqlite3') { - blocksQuery.where( - `EXISTS ( - SELECT 1 - FROM json_each(block.capture_vars) AS capture - WHERE json_extract(capture.value, '$.context_var') = :contextVar - )`, - { contextVar: this.name }, - ); - } else if (databaseType === 'postgres') { - blocksQuery.where( - `EXISTS ( - SELECT 1 - FROM json_array_elements(block.capture_vars) AS capture - WHERE capture ->> 'context_var' = :contextVar - )`, - { contextVar: this.name }, - ); - } else { - throw new Error( - `Unsupported database type for context var deletion safeguard: ${databaseType}`, - ); - } - - const blocks = await blocksQuery.getMany(); - - if (!blocks.length) { - return; - } - - const blockNames = blocks.map(({ name }) => name).join(', '); - throw new ForbiddenException( - `Context var "${this.name}" is associated with the following block(s): ${blockNames} and cannot be deleted.`, - ); - } } diff --git a/packages/api/src/chat/entities/conversation.entity.ts b/packages/api/src/chat/entities/conversation.entity.ts deleted file mode 100644 index 7203623c4..000000000 --- a/packages/api/src/chat/entities/conversation.entity.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { - Column, - Entity, - JoinColumn, - JoinTable, - ManyToMany, - ManyToOne, - RelationId, -} from 'typeorm'; - -import { JsonColumn } from '@/database/decorators/json-column.decorator'; -import { BaseOrmEntity } from '@/database/entities/base.entity'; -import { UserOrmEntity } from '@/user/entities/user.entity'; -import { AsRelation } from '@/utils/decorators/relation-ref.decorator'; - -import { getDefaultConversationContext } from '../constants/conversation'; -import { Context } from '../types/context'; - -import { BlockOrmEntity } from './block.entity'; - -@Entity({ name: 'conversations' }) -export class ConversationOrmEntity extends BaseOrmEntity { - @ManyToOne(() => UserOrmEntity, { - nullable: false, - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'sender_id' }) - @AsRelation() - sender!: UserOrmEntity; - - @RelationId((conversation: ConversationOrmEntity) => conversation.sender) - private readonly senderId!: string; - - @Column({ default: true }) - active!: boolean; - - @JsonColumn({ default: JSON.stringify(getDefaultConversationContext()) }) - context: Context; - - @ManyToOne(() => BlockOrmEntity, { - nullable: true, - onDelete: 'SET NULL', - }) - @JoinColumn({ name: 'current_block_id' }) - @AsRelation() - current: BlockOrmEntity | null; - - @RelationId((conversation: ConversationOrmEntity) => conversation.current) - private readonly currentBlockId?: string | null; - - @ManyToMany(() => BlockOrmEntity) - @JoinTable({ - name: 'conversation_next_blocks', - joinColumn: { name: 'conversation_id' }, - inverseJoinColumn: { name: 'block_id' }, - }) - @AsRelation({ allowArray: true }) - next: BlockOrmEntity[]; - - @RelationId((conversation: ConversationOrmEntity) => conversation.next) - private readonly nextBlockIds!: string[]; -} diff --git a/packages/api/src/chat/entities/label.entity.ts b/packages/api/src/chat/entities/label.entity.ts index 53d0120ce..11565a9ae 100644 --- a/packages/api/src/chat/entities/label.entity.ts +++ b/packages/api/src/chat/entities/label.entity.ts @@ -18,7 +18,6 @@ import { JsonColumn } from '@/database/decorators/json-column.decorator'; import { BaseOrmEntity } from '@/database/entities/base.entity'; import { AsRelation } from '@/utils/decorators/relation-ref.decorator'; -import { BlockOrmEntity } from './block.entity'; import { LabelGroupOrmEntity } from './label-group.entity'; import { SubscriberOrmEntity } from './subscriber.entity'; @@ -55,12 +54,4 @@ export class LabelOrmEntity extends BaseOrmEntity { @ManyToMany(() => SubscriberOrmEntity, (subscriber) => subscriber.labels) @AsRelation({ allowArray: true }) users?: SubscriberOrmEntity[]; - - @ManyToMany(() => BlockOrmEntity, (block) => block.trigger_labels) - @AsRelation({ allowArray: true }) - triggerBlocks?: BlockOrmEntity[]; - - @ManyToMany(() => BlockOrmEntity, (block) => block.assign_labels) - @AsRelation({ allowArray: true }) - assignedBlocks?: BlockOrmEntity[]; } diff --git a/packages/api/src/chat/entities/subscriber.entity.ts b/packages/api/src/chat/entities/subscriber.entity.ts index 92f4f633c..d37d85355 100644 --- a/packages/api/src/chat/entities/subscriber.entity.ts +++ b/packages/api/src/chat/entities/subscriber.entity.ts @@ -17,7 +17,6 @@ import { import { DatetimeColumn } from '@/database/decorators/datetime-column.decorator'; import { JsonColumn } from '@/database/decorators/json-column.decorator'; import { UserProfileOrmEntity } from '@/user/entities/user-profile.entity'; -import { UserOrmEntity } from '@/user/entities/user.entity'; import { AsRelation } from '@/utils/decorators/relation-ref.decorator'; import { SubscriberContext } from '../types/subscriberContext'; @@ -68,13 +67,13 @@ export class SubscriberOrmEntity extends UserProfileOrmEntity { @RelationId((subscriber: SubscriberOrmEntity) => subscriber.labels) private readonly labelIds!: string[]; - @ManyToOne(() => UserOrmEntity, { + @ManyToOne(() => UserProfileOrmEntity, { nullable: true, onDelete: 'SET NULL', }) @JoinColumn({ name: 'assigned_to_id' }) @AsRelation() - assignedTo: UserOrmEntity | null; + assignedTo: UserProfileOrmEntity | null; @RelationId((subscriber: SubscriberOrmEntity) => subscriber.assignedTo) private readonly assignedToId: string | null; diff --git a/packages/api/src/chat/helpers/envelope-factory.ts b/packages/api/src/chat/helpers/envelope-factory.ts index 46b87e275..f8534ce0a 100644 --- a/packages/api/src/chat/helpers/envelope-factory.ts +++ b/packages/api/src/chat/helpers/envelope-factory.ts @@ -60,7 +60,7 @@ export class EnvelopeFactory { * `You phone number is 6354-543-534` * * @param text - Text message - * @param context - Object holding context variables relative to the conversation (temporary) + * @param context - Object holding context variables relative to the chat interaction (temporary) * @param subscriberContext - Object holding context values relative to the subscriber (permanent) * @param settings - Settings Object * diff --git a/packages/api/src/chat/index.ts b/packages/api/src/chat/index.ts index bac0a54ff..09bc54e34 100644 --- a/packages/api/src/chat/index.ts +++ b/packages/api/src/chat/index.ts @@ -6,14 +6,6 @@ export * from './chat.module'; -export * from './constants/block'; - -export * from './constants/conversation'; - -export * from './controllers/block.controller'; - -export * from './controllers/category.controller'; - export * from './controllers/context-var.controller'; export * from './controllers/label-group.controller'; @@ -24,14 +16,8 @@ export * from './controllers/message.controller'; export * from './controllers/subscriber.controller'; -export * from './dto/block.dto'; - -export * from './dto/category.dto'; - export * from './dto/context-var.dto'; -export * from './dto/conversation.dto'; - export * from './dto/label-group.dto'; export * from './dto/label.dto'; @@ -40,14 +26,8 @@ export * from './dto/message.dto'; export * from './dto/subscriber.dto'; -export * from './entities/block.entity'; - -export * from './entities/category.entity'; - export * from './entities/context-var.entity'; -export * from './entities/conversation.entity'; - export * from './entities/label-group.entity'; export * from './entities/label.entity'; @@ -62,14 +42,8 @@ export * from './helpers/envelope-builder'; export * from './helpers/envelope-factory'; -export * from './repositories/block.repository'; - -export * from './repositories/category.repository'; - export * from './repositories/context-var.repository'; -export * from './repositories/conversation.repository'; - export * from './repositories/label-group.repository'; export * from './repositories/label.repository'; @@ -78,26 +52,14 @@ export * from './repositories/message.repository'; export * from './repositories/subscriber.repository'; -export * from './seeds/category.seed-model'; - -export * from './seeds/category.seed'; - export * from './seeds/context-var.seed-model'; export * from './seeds/context-var.seed'; -export * from './services/block.service'; - -export * from './services/bot.service'; - -export * from './services/category.service'; - export * from './services/chat.service'; export * from './services/context-var.service'; -export * from './services/conversation.service'; - export * from './services/label-group.service'; export * from './services/label.service'; diff --git a/packages/api/src/chat/repositories/block.repository.spec.ts b/packages/api/src/chat/repositories/block.repository.spec.ts deleted file mode 100644 index 67c5e45f7..000000000 --- a/packages/api/src/chat/repositories/block.repository.spec.ts +++ /dev/null @@ -1,526 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { randomUUID } from 'crypto'; - -import { ConflictException } from '@nestjs/common'; -import { TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { DEFAULT_BLOCK_SEARCH_LIMIT } from '@/chat/constants/block'; -import { BlockOrmEntity } from '@/chat/entities/block.entity'; -import { CategoryOrmEntity } from '@/chat/entities/category.entity'; -import { ConversationOrmEntity } from '@/chat/entities/conversation.entity'; -import { SubscriberOrmEntity } from '@/chat/entities/subscriber.entity'; -import { SettingOrmEntity } from '@/setting/entities/setting.entity'; -import { SettingType } from '@/setting/types'; -import { - blockFixtures, - installBlockFixturesTypeOrm, -} from '@/utils/test/fixtures/block'; -import { closeTypeOrmConnections } from '@/utils/test/test'; -import { buildTestingMocks } from '@/utils/test/utils'; - -import { BlockCreateDto, BlockFull } from '../dto/block.dto'; - -import { BlockRepository } from './block.repository'; - -describe('BlockRepository (TypeORM)', () => { - let module: TestingModule; - let blockRepository: BlockRepository; - let blockOrmRepository: Repository; - let categoryRepository: Repository; - let conversationRepository: Repository; - let subscriberRepository: Repository; - let settingRepository: Repository; - - let hasNextBlock: BlockFull; - let hasPreviousBlock: BlockFull; - let defaultCategoryId: string; - - const createdBlockIds: string[] = []; - const createdCategoryIds: string[] = []; - const createdConversationIds: string[] = []; - const createdSubscriberIds: string[] = []; - const createdSettingIds: string[] = []; - - beforeAll(async () => { - const testing = await buildTestingMocks({ - autoInjectFrom: ['providers'], - providers: [BlockRepository], - typeorm: { - fixtures: installBlockFixturesTypeOrm, - }, - }); - - module = testing.module; - blockRepository = module.get(BlockRepository); - blockOrmRepository = module.get>( - getRepositoryToken(BlockOrmEntity), - ); - categoryRepository = module.get>( - getRepositoryToken(CategoryOrmEntity), - ); - conversationRepository = module.get>( - getRepositoryToken(ConversationOrmEntity), - ); - subscriberRepository = module.get>( - getRepositoryToken(SubscriberOrmEntity), - ); - settingRepository = module.get>( - getRepositoryToken(SettingOrmEntity), - ); - - const populatedBlocks = await blockRepository.findAndPopulate({ - order: { name: 'ASC' }, - }); - - if (populatedBlocks.length !== blockFixtures.length) { - throw new Error('Block fixtures were not loaded as expected'); - } - - const blockMap = new Map( - populatedBlocks.map((block) => [block.name, block]), - ); - - hasNextBlock = blockMap.get('hasNextBlocks') as BlockFull; - hasPreviousBlock = blockMap.get('hasPreviousBlocks') as BlockFull; - - if (!hasNextBlock || !hasPreviousBlock) { - throw new Error('Required block fixtures are missing'); - } - - defaultCategoryId = hasNextBlock.category?.id ?? ''; - - if (!defaultCategoryId) { - throw new Error('Default category could not be resolved from fixtures'); - } - }); - - const buildBlockPayload = ( - overrides: Partial = {}, - ): BlockCreateDto => ({ - name: `block-${randomUUID()}`, - patterns: ['pattern'], - outcomes: [], - trigger_labels: [], - assign_labels: [], - trigger_channels: [], - options: {}, - message: ['hello'], - nextBlocks: [], - attachedBlock: null, - category: defaultCategoryId, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - ...overrides, - }); - const createBlock = async ( - overrides: Partial = {}, - ): Promise => { - const payload = buildBlockPayload(overrides); - const created = await blockRepository.create(payload); - createdBlockIds.push(created.id); - const full = await blockRepository.findOneAndPopulate(created.id); - if (!full) { - throw new Error('Failed to load created block'); - } - - return full; - }; - const createCategory = async (label?: string): Promise => { - const category = await categoryRepository.save( - categoryRepository.create({ - label: label ?? `category-${randomUUID()}`, - builtin: false, - zoom: 100, - offset: [0, 0], - }), - ); - createdCategoryIds.push(category.id); - - return category; - }; - const createSubscriber = async (): Promise => { - const subscriber = await subscriberRepository.save( - subscriberRepository.create({ - firstName: 'Test', - lastName: 'User', - locale: null, - timezone: 0, - gender: null, - country: null, - foreignId: randomUUID(), - labels: [], - channel: { name: 'test-channel' }, - context: { vars: {} }, - }), - ); - createdSubscriberIds.push(subscriber.id); - - return subscriber; - }; - const createConversation = async ( - blockId: string, - subscriberId: string, - ): Promise => { - const conversation = await conversationRepository.save( - conversationRepository.create({ - sender: { id: subscriberId } as SubscriberOrmEntity, - active: true, - context: { vars: {} }, - current: { id: blockId } as BlockOrmEntity, - next: [], - }), - ); - createdConversationIds.push(conversation.id); - - return conversation; - }; - const createSetting = async ( - data: Partial & - Pick, - ): Promise => { - const setting = await settingRepository.save( - settingRepository.create({ - subgroup: undefined, - options: undefined, - config: undefined, - weight: 0, - translatable: false, - ...data, - }), - ); - createdSettingIds.push(setting.id); - - return setting; - }; - - afterEach(async () => { - jest.clearAllMocks(); - - if (createdConversationIds.length) { - await conversationRepository.delete(createdConversationIds); - createdConversationIds.length = 0; - } - - if (createdSubscriberIds.length) { - await subscriberRepository.delete(createdSubscriberIds); - createdSubscriberIds.length = 0; - } - - if (createdSettingIds.length) { - await settingRepository.delete(createdSettingIds); - createdSettingIds.length = 0; - } - - if (createdBlockIds.length) { - await blockOrmRepository.delete(createdBlockIds); - createdBlockIds.length = 0; - } - - if (createdCategoryIds.length) { - await categoryRepository.delete(createdCategoryIds); - createdCategoryIds.length = 0; - } - }); - - afterAll(async () => { - if (module) { - await module.close(); - } - - await closeTypeOrmConnections(); - }); - - describe('findOneAndPopulate', () => { - it('loads a single block with its relations', async () => { - const result = await blockRepository.findOneAndPopulate(hasNextBlock.id); - - expect(result).not.toBeNull(); - expect(result!.id).toBe(hasNextBlock.id); - expect(result!.category?.id).toBe(defaultCategoryId); - - const nextNames = (result!.nextBlocks ?? []).map((block) => block.name); - expect(nextNames).toContain(hasPreviousBlock.name); - - expect(result!.previousBlocks ?? []).toHaveLength(0); - expect(result!.attachedBlock ?? null).toBeNull(); - expect(result!.attachedToBlock ?? null).toBeNull(); - expect(result!.trigger_labels).toEqual([]); - expect(result!.assign_labels).toEqual([]); - }); - }); - - describe('findAndPopulate', () => { - it('hydrates relations for each block', async () => { - const populated = await blockRepository.findAndPopulate({ - order: { name: 'ASC' }, - }); - - expect(populated).toHaveLength(blockFixtures.length); - populated.forEach((block) => { - expect(block.category?.id).toBe(defaultCategoryId); - expect(block.trigger_labels).toEqual([]); - expect(block.assign_labels).toEqual([]); - }); - - const blockByName = new Map( - populated.map((block) => [block.name, block]), - ); - const nextBlock = blockByName.get('hasNextBlocks'); - expect(nextBlock).toBeDefined(); - expect((nextBlock!.nextBlocks ?? []).map((b) => b.name)).toEqual([ - 'hasPreviousBlocks', - ]); - expect(nextBlock!.previousBlocks ?? []).toHaveLength(0); - - const previousBlock = blockByName.get('hasPreviousBlocks'); - expect(previousBlock).toBeDefined(); - expect((previousBlock!.previousBlocks ?? []).map((b) => b.name)).toEqual([ - 'hasNextBlocks', - ]); - }); - }); - - describe('findByContextVarName', () => { - it('returns an empty array when the name is blank', async () => { - const repositoryFindSpy = jest.spyOn(blockOrmRepository, 'find'); - const result = await blockRepository.findByContextVarName(''); - - expect(result).toEqual([]); - expect(repositoryFindSpy).not.toHaveBeenCalled(); - }); - - it('finds blocks that capture the provided context variable', async () => { - const contextVarName = `context_var_${randomUUID().replace(/-/g, '')}`; - const created = await blockRepository.create({ - name: `context-block-${contextVarName}`, - patterns: ['test'], - outcomes: [], - trigger_labels: [], - assign_labels: [], - trigger_channels: [], - options: {}, - message: ['Hello'], - nextBlocks: [], - attachedBlock: null, - category: defaultCategoryId, - starts_conversation: false, - capture_vars: [ - { - entity: -1, - context_var: contextVarName, - }, - ], - position: { x: 10, y: 20 }, - }); - - createdBlockIds.push(created.id); - - const results = - await blockRepository.findByContextVarName(contextVarName); - - expect(results.map((block) => block.id)).toContain(created.id); - }); - }); - - describe('search', () => { - it('returns an empty array without querying the database when the search term is blank', async () => { - const createQueryBuilderSpy = jest.spyOn( - blockOrmRepository, - 'createQueryBuilder', - ); - const result = await blockRepository.search(' '); - - expect(result).toEqual([]); - expect(createQueryBuilderSpy).not.toHaveBeenCalled(); - }); - - it('searches blocks by name', async () => { - const results = await blockRepository.search(hasNextBlock.name, 10); - - expect(results.length).toBeGreaterThan(0); - expect( - results.find((block) => block.id === hasNextBlock.id), - ).toBeDefined(); - }); - - it('searches blocks by message content', async () => { - const results = await blockRepository.search('Hi back', 10); - - expect(results.length).toBeGreaterThan(0); - expect( - results.find((block) => block.name === 'hasNextBlocks'), - ).toBeDefined(); - }); - - it('filters results by category when provided', async () => { - const results = await blockRepository.search( - hasNextBlock.name, - 10, - defaultCategoryId, - ); - - expect(results.length).toBeGreaterThan(0); - results.forEach((block) => { - expect(block.category).toBe(defaultCategoryId); - }); - }); - - it('respects the requested limit and orders scores in descending order', async () => { - const results = await blockRepository.search('block', 1); - - expect(results.length).toBeLessThanOrEqual(1); - const scores = results.map((block) => block.score); - const sortedScores = [...scores].sort((a, b) => b - a); - expect(scores).toEqual(sortedScores); - - const cappedResults = await blockRepository.search( - 'block', - DEFAULT_BLOCK_SEARCH_LIMIT + 5, - ); - expect(cappedResults.length).toBeLessThanOrEqual( - Math.min(blockFixtures.length, DEFAULT_BLOCK_SEARCH_LIMIT), - ); - }); - }); - - describe('lifecycle hooks', () => { - describe('@BeforeUpdate enforceCategoryConsistency', () => { - it('detaches relationships that belong to another category before saving', async () => { - const blockToMove = await createBlock({ - name: `move-${randomUUID()}`, - }); - const relatedBlock = await createBlock({ - name: `related-${randomUUID()}`, - }); - const upstreamBlock = await createBlock({ - name: `upstream-${randomUUID()}`, - }); - - await blockOrmRepository - .createQueryBuilder() - .relation(BlockOrmEntity, 'nextBlocks') - .of(blockToMove.id) - .add(relatedBlock.id); - - await blockRepository.updateOne(blockToMove.id, { - attachedBlock: relatedBlock.id, - }); - - await blockRepository.updateOne(upstreamBlock.id, { - attachedBlock: blockToMove.id, - }); - - const newCategory = await createCategory(); - const updatedBlock = await blockRepository.updateOne(blockToMove.id, { - category: newCategory.id, - }); - - expect(updatedBlock.category).toBe(newCategory.id); - - const [reloadedBlock, reloadedRelated, reloadedUpstream] = - await Promise.all([ - blockRepository.findOneAndPopulate(blockToMove.id), - blockRepository.findOneAndPopulate(relatedBlock.id), - blockRepository.findOneAndPopulate(upstreamBlock.id), - ]); - - expect(reloadedBlock).not.toBeNull(); - expect(reloadedBlock!.category?.id).toBe(newCategory.id); - expect(reloadedBlock!.nextBlocks ?? []).toHaveLength(0); - expect(reloadedBlock!.attachedBlock ?? null).toBeNull(); - expect(reloadedBlock!.attachedToBlock ?? null).toBeNull(); - - const previousIds = (reloadedRelated!.previousBlocks ?? []).map( - (block) => block.id, - ); - expect(previousIds).not.toContain(blockToMove.id); - - expect(reloadedUpstream!.attachedBlock ?? null).toBeNull(); - }); - }); - - describe('@BeforeRemove ensureDeletable', () => { - it('removes inbound references before deleting a block', async () => { - const blockToDelete = await createBlock({ - name: `delete-${randomUUID()}`, - }); - const attachmentSource = await createBlock({ - name: `attachment-${randomUUID()}`, - }); - const flowSource = await createBlock({ - name: `flow-${randomUUID()}`, - }); - - await blockRepository.updateOne(attachmentSource.id, { - attachedBlock: blockToDelete.id, - }); - - await blockOrmRepository - .createQueryBuilder() - .relation(BlockOrmEntity, 'nextBlocks') - .of(flowSource.id) - .add(blockToDelete.id); - - const result = await blockRepository.deleteOne(blockToDelete.id); - - expect(result.deletedCount).toBe(1); - - const [updatedAttachmentSource, updatedFlowSource] = await Promise.all([ - blockRepository.findOneAndPopulate(attachmentSource.id), - blockRepository.findOneAndPopulate(flowSource.id), - ]); - - expect(updatedAttachmentSource!.attachedBlock ?? null).toBeNull(); - - const downstreamIds = (updatedFlowSource!.nextBlocks ?? []).map( - (block) => block.id, - ); - expect(downstreamIds).not.toContain(blockToDelete.id); - }); - - it('prevents deletion when the block participates in an active conversation', async () => { - const blockInUse = await createBlock({ - name: `in-use-${randomUUID()}`, - }); - const subscriber = await createSubscriber(); - await createConversation(blockInUse.id, subscriber.id); - - await expect( - blockRepository.deleteOne(blockInUse.id), - ).rejects.toBeInstanceOf(ConflictException); - }); - - it('prevents deletion when the block is configured as the global fallback', async () => { - const fallbackBlock = await createBlock({ - name: `fallback-${randomUUID()}`, - }); - - await createSetting({ - group: 'chatbot_settings', - label: 'fallback_block', - type: SettingType.select, - value: fallbackBlock.id, - }); - - await createSetting({ - group: 'chatbot_settings', - label: 'global_fallback', - type: SettingType.checkbox, - value: true, - }); - - await expect( - blockRepository.deleteOne(fallbackBlock.id), - ).rejects.toBeInstanceOf(ConflictException); - }); - }); - }); -}); diff --git a/packages/api/src/chat/repositories/block.repository.ts b/packages/api/src/chat/repositories/block.repository.ts deleted file mode 100644 index c699970ce..000000000 --- a/packages/api/src/chat/repositories/block.repository.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, Repository } from 'typeorm'; - -import { BaseOrmRepository } from '@/utils/generics/base-orm.repository'; -import { DtoTransformer } from '@/utils/types/dto.types'; - -import { DEFAULT_BLOCK_SEARCH_LIMIT } from '../constants/block'; -import { - Block, - BlockDtoConfig, - BlockFull, - BlockTransformerDto, - SearchRankedBlock, -} from '../dto/block.dto'; -import { BlockOrmEntity } from '../entities/block.entity'; - -@Injectable() -export class BlockRepository extends BaseOrmRepository< - BlockOrmEntity, - BlockTransformerDto, - BlockDtoConfig -> { - constructor( - @InjectRepository(BlockOrmEntity) - repository: Repository, - ) { - super( - repository, - [ - 'trigger_labels', - 'assign_labels', - 'nextBlocks', - 'attachedBlock', - 'category', - 'previousBlocks', - 'attachedToBlock', - ], - { - PlainCls: Block, - FullCls: BlockFull, - }, - ); - } - - /** - * Performs a full-text search on blocks using a case-insensitive LIKE pattern. - * - * @param query - Text to search for. - * @param limit - Maximum number of results to return. - * @param category - Optional category filter. - */ - async search( - query: string, - limit = DEFAULT_BLOCK_SEARCH_LIMIT, - category?: string, - ): Promise { - const sanitized = query?.trim(); - if (!sanitized) { - return []; - } - - const cappedLimit = Math.min( - Math.max(1, limit ?? DEFAULT_BLOCK_SEARCH_LIMIT), - DEFAULT_BLOCK_SEARCH_LIMIT, - ); - const pattern = `%${this.escapeLikePattern(sanitized)}%`; - - try { - const driverType = this.repository.manager.connection.options?.type as - | string - | undefined; - const likeOperator = - driverType && - ['sqlite', 'better-sqlite3', 'capacitor'].includes(driverType) - ? 'LIKE' - : 'ILIKE'; - const qb = this.repository - .createQueryBuilder('block') - .where( - new Brackets((where) => { - where - .where(`block.name ${likeOperator} :pattern`, { pattern }) - .orWhere(`CAST(block.message AS TEXT) ${likeOperator} :pattern`, { - pattern, - }) - .orWhere(`CAST(block.options AS TEXT) ${likeOperator} :pattern`, { - pattern, - }); - }), - ) - .orderBy('block.created_at', 'DESC') - .limit(cappedLimit); - - if (category) { - qb.andWhere('block.category_id = :category', { category }); - } - - const entities = await qb.getMany(); - const toDto = this.getTransformer(DtoTransformer.PlainCls); - - return entities.map((entity, index) => { - const dto = toDto(entity) as Block; - const score = entities.length - index; - - return Object.assign(new SearchRankedBlock(), dto, { score }); - }); - } catch (error) { - this.logger?.error('Block search failed', error); - throw error; - } - } - - /** - * Finds blocks referencing the provided context variable inside `capture_vars`. - * - * @param name Context variable unique name. - */ - async findByContextVarName(name: string): Promise { - if (!name) { - return []; - } - - const toDto = this.getTransformer(DtoTransformer.PlainCls); - const driverType = ( - this.repository.manager.connection.options?.type ?? '' - ).toString(); - const qb = this.repository.createQueryBuilder('block'); - - if (['sqlite', 'better-sqlite3', 'capacitor'].includes(driverType)) { - qb.where( - `EXISTS ( - SELECT 1 - FROM json_each(block.capture_vars) AS elem - WHERE json_extract(elem.value, '$.context_var') = :name - )`, - { name }, - ); - } else { - qb.where( - `EXISTS ( - SELECT 1 - FROM jsonb_array_elements(block.capture_vars::jsonb) AS elem - WHERE elem->>'context_var' = :name - )`, - { name }, - ); - } - - const entities = await qb.getMany(); - - return entities.map(toDto); - } - - private escapeLikePattern(value: string): string { - return value.replace(/[%_]/g, '\\$&'); - } -} diff --git a/packages/api/src/chat/repositories/category.repository.ts b/packages/api/src/chat/repositories/category.repository.ts deleted file mode 100644 index 1ce4171b6..000000000 --- a/packages/api/src/chat/repositories/category.repository.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { BaseOrmRepository } from '@/utils/generics/base-orm.repository'; - -import { - Category, - CategoryDtoConfig, - CategoryFull, - CategoryTransformerDto, -} from '../dto/category.dto'; -import { CategoryOrmEntity } from '../entities/category.entity'; - -@Injectable() -export class CategoryRepository extends BaseOrmRepository< - CategoryOrmEntity, - CategoryTransformerDto, - CategoryDtoConfig -> { - constructor( - @InjectRepository(CategoryOrmEntity) - repository: Repository, - ) { - super(repository, ['blocks'], { - PlainCls: Category, - FullCls: CategoryFull, - }); - } -} diff --git a/packages/api/src/chat/repositories/conversation.repository.ts b/packages/api/src/chat/repositories/conversation.repository.ts deleted file mode 100644 index 568ad5841..000000000 --- a/packages/api/src/chat/repositories/conversation.repository.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { BaseOrmRepository } from '@/utils/generics/base-orm.repository'; - -import { - Conversation, - ConversationDtoConfig, - ConversationFull, - ConversationTransformerDto, -} from '../dto/conversation.dto'; -import { ConversationOrmEntity } from '../entities/conversation.entity'; - -@Injectable() -export class ConversationRepository extends BaseOrmRepository< - ConversationOrmEntity, - ConversationTransformerDto, - ConversationDtoConfig -> { - constructor( - @InjectRepository(ConversationOrmEntity) - repository: Repository, - ) { - super(repository, ['sender', 'current', 'next'], { - PlainCls: Conversation, - FullCls: ConversationFull, - }); - } - - /** - * Marks a conversation as ended by setting its `active` status to `false`. - * - * @param convo The conversation or full conversation object to be ended. - * - * @returns A promise resolving to the result of the update operation. - */ - async end(convo: Conversation | ConversationFull): Promise { - return await this.updateOne(convo.id, { active: false }); - } -} diff --git a/packages/api/src/chat/seeds/category.seed-model.ts b/packages/api/src/chat/seeds/category.seed-model.ts deleted file mode 100644 index 10cd68764..000000000 --- a/packages/api/src/chat/seeds/category.seed-model.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { CategoryCreateDto } from '../dto/category.dto'; - -export const categoryModels: CategoryCreateDto[] = [ - { - label: 'Default', - }, -]; diff --git a/packages/api/src/chat/seeds/category.seed.ts b/packages/api/src/chat/seeds/category.seed.ts deleted file mode 100644 index 19984f560..000000000 --- a/packages/api/src/chat/seeds/category.seed.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; - -import { BaseOrmSeeder } from '@/utils/generics/base-orm.seeder'; - -import { CategoryDtoConfig, CategoryTransformerDto } from '../dto/category.dto'; -import { CategoryOrmEntity } from '../entities/category.entity'; -import { CategoryRepository } from '../repositories/category.repository'; - -@Injectable() -export class CategorySeeder extends BaseOrmSeeder< - CategoryOrmEntity, - CategoryTransformerDto, - CategoryDtoConfig -> { - constructor(private readonly categoryRepository: CategoryRepository) { - super(categoryRepository); - } -} diff --git a/packages/api/src/chat/services/block.service.spec.ts b/packages/api/src/chat/services/block.service.spec.ts deleted file mode 100644 index 7ae7a3543..000000000 --- a/packages/api/src/chat/services/block.service.spec.ts +++ /dev/null @@ -1,1130 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { TestingModule } from '@nestjs/testing'; - -import { - subscriberWithLabels, - subscriberWithoutLabels, -} from '@/channel/lib/__test__/subscriber.mock'; -import { ButtonType, PayloadType } from '@/chat/types/button'; -import { ContentOrmEntity } from '@/cms/entities/content.entity'; -import { ContentTypeService } from '@/cms/services/content-type.service'; -import { ContentService } from '@/cms/services/content.service'; -import WebChannelHandler from '@/extensions/channels/web/index.channel'; -import { WEB_CHANNEL_NAME } from '@/extensions/channels/web/settings'; -import { Web } from '@/extensions/channels/web/types'; -import WebEventWrapper from '@/extensions/channels/web/wrapper'; -import { I18nService } from '@/i18n/services/i18n.service'; -import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp'; -import { - blockFixtures, - installBlockFixturesTypeOrm, -} from '@/utils/test/fixtures/block'; -import { installContentFixturesTypeOrm } from '@/utils/test/fixtures/content'; -import { installNlpValueFixturesTypeOrm } from '@/utils/test/fixtures/nlpvalue'; -import { - blockEmpty, - blockGetStarted, - blockProductListMock, - blockMocks as blocks, - mockNlpAffirmationPatterns, - mockNlpFirstNamePatterns, - mockNlpGreetingAnyNamePatterns, - mockNlpGreetingNamePatterns, - mockNlpGreetingPatterns, - mockNlpGreetingWrongNamePatterns, - mockWebChannelData, -} from '@/utils/test/mocks/block'; -import { - contextBlankInstance, - subscriberContextBlankInstance, -} from '@/utils/test/mocks/conversation'; -import { - mockNlpFirstNameEntities, - mockNlpGreetingFullNameEntities, - mockNlpGreetingNameEntities, -} from '@/utils/test/mocks/nlp'; -import { closeTypeOrmConnections } from '@/utils/test/test'; -import { buildTestingMocks } from '@/utils/test/utils'; - -import { Block, BlockFull } from '../dto/block.dto'; -import { Category } from '../dto/category.dto'; -import { Subscriber } from '../dto/subscriber.dto'; -import { BlockRepository } from '../repositories/block.repository'; -import { FileType } from '../types/attachment'; -import { Context } from '../types/context'; -import { - OutgoingMessageFormat, - StdOutgoingListMessage, -} from '../types/message'; -import { QuickReplyType } from '../types/quick-reply'; - -import { CategoryRepository } from './../repositories/category.repository'; -import { BlockService } from './block.service'; - -function makeMockBlock(overrides: Partial): Block { - return { - id: 'default', - message: [], - trigger_labels: [], - assign_labels: [], - nextBlocks: [], - attachedBlock: null, - category: null, - name: '', - patterns: [], - outcomes: [], - trigger_channels: [], - options: {}, - starts_conversation: false, - capture_vars: [], - position: { x: 0, y: 0 }, - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -const ATTACHMENT_ID = '99999999-9999-4999-9999-999999999999'; - -describe('BlockService (TypeORM)', () => { - let module: TestingModule; - let blockRepository: BlockRepository; - let categoryRepository: CategoryRepository; - let category: Category; - let block: Block; - let blockService: BlockService; - let hasPreviousBlocks: Block; - let contentService: ContentService; - let contentTypeService: ContentTypeService; - - beforeAll(async () => { - const testing = await buildTestingMocks({ - autoInjectFrom: ['providers'], - providers: [ - BlockService, - ContentTypeService, - CategoryRepository, - { - provide: I18nService, - useValue: { - t: jest.fn().mockImplementation((t) => { - return t === 'Welcome' ? 'Bienvenue' : t; - }), - }, - }, - ], - typeorm: { - fixtures: [ - installContentFixturesTypeOrm, - installBlockFixturesTypeOrm, - installNlpValueFixturesTypeOrm, - ], - }, - }); - - module = testing.module; - - [ - blockService, - contentService, - contentTypeService, - categoryRepository, - blockRepository, - ] = await testing.getMocks([ - BlockService, - ContentService, - ContentTypeService, - CategoryRepository, - BlockRepository, - ]); - category = (await categoryRepository.findOne({ - where: { label: 'default' }, - }))!; - hasPreviousBlocks = (await blockRepository.findOne({ - where: { name: 'hasPreviousBlocks' }, - }))!; - block = (await blockRepository.findOne({ - where: { name: 'hasNextBlocks' }, - }))!; - }); - - afterEach(jest.clearAllMocks); - afterAll(async () => { - if (module) { - await module.close(); - } - await closeTypeOrmConnections(); - }); - - describe('findOneAndPopulate', () => { - it('should find one block by id, and populate its trigger_labels, assign_labels,attachedBlock,category,nextBlocks', async () => { - jest.spyOn(blockRepository, 'findOneAndPopulate'); - const result = await blockService.findOneAndPopulate(block.id); - - expect(blockRepository.findOneAndPopulate).toHaveBeenCalledWith(block.id); - expect(result).toEqualPayload({ - ...blockFixtures.find(({ name }) => name === 'hasNextBlocks'), - category, - nextBlocks: [hasPreviousBlocks], - previousBlocks: [], - attachedBlock: null, - attachedToBlock: null, - }); - }); - }); - - describe('findAndPopulate', () => { - it('should find blocks and populate them', async () => { - jest.spyOn(blockRepository, 'findAndPopulate'); - const result = await blockService.findAndPopulate({}); - const blocksWithCategory = blockFixtures.map((blockFixture) => ({ - ...blockFixture, - category, - previousBlocks: - blockFixture.name === 'hasPreviousBlocks' ? [block] : [], - nextBlocks: - blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [], - attachedBlock: null, - attachedToBlock: null, - })); - - expect(blockRepository.findAndPopulate).toHaveBeenCalledWith({}); - expect(result).toEqualPayload(blocksWithCategory); - }); - }); - - describe('match', () => { - const handlerMock = { - getName: jest.fn(() => WEB_CHANNEL_NAME), - } as any as WebChannelHandler; - const webEventGreeting = new WebEventWrapper( - handlerMock, - { - type: Web.IncomingMessageType.text, - data: { - text: 'Hello', - }, - }, - mockWebChannelData, - ); - const webEventGetStarted = new WebEventWrapper( - handlerMock, - { - type: Web.IncomingMessageType.postback, - data: { - text: 'Get Started', - payload: 'GET_STARTED', - }, - }, - mockWebChannelData, - ); - const webEventAmbiguous = new WebEventWrapper( - handlerMock, - { - type: Web.IncomingMessageType.text, - data: { - text: "It's not a yes or no answer!", - }, - }, - mockWebChannelData, - ); - - it('should return undefined when no blocks are provided', async () => { - const result = await blockService.match([], webEventGreeting); - expect(result).toBe(undefined); - }); - - it('should return undefined for empty blocks', async () => { - const result = await blockService.match([blockEmpty], webEventGreeting); - expect(result).toEqual(undefined); - }); - - it('should return undefined for no matching labels', async () => { - webEventGreeting.setSender(subscriberWithoutLabels); - const result = await blockService.match(blocks, webEventGreeting); - expect(result).toEqual(undefined); - }); - - it('should match block text and labels', async () => { - webEventGreeting.setSender(subscriberWithLabels); - const result = await blockService.match(blocks, webEventGreeting); - expect(result).toEqual(blockGetStarted); - }); - - it('should return undefined when multiple matches are not allowed', async () => { - const result = await blockService.match( - [ - { - ...blockEmpty, - patterns: ['/yes/'], - }, - { - ...blockEmpty, - patterns: ['/no/'], - }, - ], - webEventAmbiguous, - false, - ); - expect(result).toEqual(undefined); - }); - - it('should match block with payload', async () => { - webEventGetStarted.setSender(subscriberWithLabels); - const result = await blockService.match(blocks, webEventGetStarted); - expect(result).toEqual(blockGetStarted); - }); - - it('should match block with nlp', async () => { - webEventGreeting.setSender(subscriberWithLabels); - webEventGreeting.setNLP(mockNlpGreetingFullNameEntities); - const result = await blockService.match(blocks, webEventGreeting); - expect(result).toEqual(blockGetStarted); - }); - }); - - describe('matchNLP', () => { - it('should return an empty array for a block with no NLP patterns', () => { - const result = blockService.getMatchingNluPatterns( - mockNlpGreetingFullNameEntities, - blockEmpty, - ); - expect(result).toEqual([]); - }); - - it('should return an empty array when no NLP entities are provided', () => { - const result = blockService.getMatchingNluPatterns( - { entities: [] }, - blockGetStarted, - ); - expect(result).toEqual([]); - }); - - it('should return match nlp patterns', () => { - const result = blockService.getMatchingNluPatterns( - mockNlpGreetingFullNameEntities, - { - ...blockGetStarted, - patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns], - }, - ); - expect(result).toEqual([ - [ - { - entity: 'intent', - match: 'value', - value: 'greeting', - }, - { - entity: 'firstname', - match: 'value', - value: 'jhon', - }, - ], - ]); - }); - - it('should return match nlp patterns with synonyms match (canonical value)', () => { - const result = blockService.getMatchingNluPatterns( - mockNlpFirstNameEntities, - { - ...blockGetStarted, - patterns: [...blockGetStarted.patterns, mockNlpFirstNamePatterns], - }, - ); - expect(result).toEqual([ - [ - { - entity: 'firstname', - match: 'value', - value: 'jhon', - }, - ], - ]); - }); - - it('should return empty array when it does not match nlp patterns', () => { - const result = blockService.getMatchingNluPatterns( - mockNlpGreetingFullNameEntities, - { - ...blockGetStarted, - patterns: [ - [{ entity: 'lastname', match: 'value', value: 'Belakhel' }], - ], - }, - ); - expect(result).toEqual([]); - }); - - it('should return empty array when unknown nlp patterns', () => { - const result = blockService.getMatchingNluPatterns( - mockNlpGreetingFullNameEntities, - { - ...blockGetStarted, - patterns: [[{ entity: 'product', match: 'value', value: 'pizza' }]], - }, - ); - expect(result).toEqual([]); - }); - }); - - describe('matchBestNLP', () => { - it('should return the block with the highest NLP score', async () => { - const mockExpectedBlock: BlockFull = { - ...blockGetStarted, - patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns], - }; - const blocks: BlockFull[] = [ - // no match - blockGetStarted, - // match - mockExpectedBlock, - // match - { - ...blockGetStarted, - patterns: [...blockGetStarted.patterns, mockNlpGreetingPatterns], - }, - // no match - { - ...blockGetStarted, - patterns: [ - ...blockGetStarted.patterns, - mockNlpGreetingWrongNamePatterns, - ], - }, - // no match - { - ...blockGetStarted, - patterns: [...blockGetStarted.patterns, mockNlpAffirmationPatterns], - }, - // no match - blockGetStarted, - ]; - // Spy on calculateBlockScore to check if it's called - const calculateBlockScoreSpy = jest.spyOn( - blockService, - 'calculateNluPatternMatchScore', - ); - const bestBlock = blockService.matchBestNLP( - blocks, - mockNlpGreetingNameEntities, - FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, - ); - - // Ensure calculateBlockScore was called at least once for each block - expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(2); // Called for each block - - // Assert that the block with the highest NLP score is selected - expect(bestBlock).toEqual(mockExpectedBlock); - }); - - it('should return the block with the highest NLP score applying penalties', async () => { - const mockExpectedBlock: BlockFull = { - ...blockGetStarted, - patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns], - }; - const blocks: BlockFull[] = [ - // no match - blockGetStarted, - // match - mockExpectedBlock, - // match - { - ...blockGetStarted, - patterns: [...blockGetStarted.patterns, mockNlpGreetingPatterns], - }, - // match - { - ...blockGetStarted, - patterns: [ - ...blockGetStarted.patterns, - mockNlpGreetingAnyNamePatterns, - ], - }, - ]; - const nlp = mockNlpGreetingNameEntities; - // Spy on calculateBlockScore to check if it's called - const calculateBlockScoreSpy = jest.spyOn( - blockService, - 'calculateNluPatternMatchScore', - ); - const bestBlock = blockService.matchBestNLP( - blocks, - nlp, - FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, - ); - - // Ensure calculateBlockScore was called at least once for each block - expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(3); // Called for each block - - // Assert that the block with the highest NLP score is selected - expect(bestBlock).toEqual(mockExpectedBlock); - }); - - it('should return undefined if no blocks match or the list is empty', async () => { - const blocks: BlockFull[] = [ - { - ...blockGetStarted, - patterns: [...blockGetStarted.patterns, mockNlpAffirmationPatterns], - }, - blockGetStarted, - ]; - const bestBlock = blockService.matchBestNLP( - blocks, - mockNlpGreetingNameEntities, - FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, - ); - - // Assert that undefined is returned when no blocks are available - expect(bestBlock).toBeUndefined(); - }); - }); - - describe('calculateNluPatternMatchScore', () => { - it('should calculate the correct NLP score for a block', async () => { - const matchingScore = blockService.calculateNluPatternMatchScore( - mockNlpGreetingNamePatterns, - mockNlpGreetingNameEntities, - FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, - ); - - expect(matchingScore).toBeGreaterThan(0); - }); - - it('should calculate the correct NLP score for a block and apply penalties ', async () => { - const scoreWithoutPenalty = blockService.calculateNluPatternMatchScore( - mockNlpGreetingNamePatterns, - mockNlpGreetingNameEntities, - FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, - ); - const scoreWithPenalty = blockService.calculateNluPatternMatchScore( - mockNlpGreetingAnyNamePatterns, - mockNlpGreetingNameEntities, - FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, - ); - - expect(scoreWithoutPenalty).toBeGreaterThan(scoreWithPenalty); - }); - - it('should handle invalid case for penalty factor values', async () => { - // Test with invalid penalty (should use fallback) - const scoreWithInvalidPenalty = - blockService.calculateNluPatternMatchScore( - mockNlpGreetingAnyNamePatterns, - mockNlpGreetingNameEntities, - -1, - ); - - expect(scoreWithInvalidPenalty).toBeGreaterThan(0); // Should use fallback value - }); - }); - - describe('matchPayload', () => { - it('should return undefined for empty payload', () => { - const result = blockService.matchPayload('', blockGetStarted); - expect(result).toEqual(undefined); - }); - - it('should return undefined for empty block', () => { - const result = blockService.matchPayload('test', blockEmpty); - expect(result).toEqual(undefined); - }); - - it('should match payload and return object for label string', () => { - const location = { - label: 'Tounes', - value: 'Tounes', - type: 'location', - }; - const result = blockService.matchPayload('Tounes', blockGetStarted); - expect(result).toEqual(location); - }); - - it('should match payload and return object for value string', () => { - const result = blockService.matchPayload('GET_STARTED', blockGetStarted); - expect(result).toEqual({ - label: 'Get Started', - value: 'GET_STARTED', - }); - }); - - it("should match payload when it's an attachment location", () => { - const result = blockService.matchPayload( - { - type: PayloadType.location, - coordinates: { - lat: 15, - lon: 23, - }, - }, - blockGetStarted, - ); - expect(result).toEqual(blockGetStarted.patterns?.[3]); - }); - - it("should match payload when it's an attachment file", () => { - const result = blockService.matchPayload( - { - type: PayloadType.attachments, - attachment: { - type: FileType.file, - payload: { - id: ATTACHMENT_ID, - url: 'http://link.to/the/file', - }, - }, - }, - blockGetStarted, - ); - expect(result).toEqual(blockGetStarted.patterns?.[4]); - }); - }); - - describe('matchText', () => { - it('should return false for matching an empty text', () => { - const result = blockService.matchText('', blockGetStarted); - expect(result).toEqual(false); - }); - - it('should match text message', () => { - const result = blockService.matchText('Hello', blockGetStarted); - expect(result).toEqual(['Hello']); - }); - - it('should match regex text message', () => { - const result = blockService.matchText( - 'weeeelcome to our house', - blockGetStarted, - ); - expect(result).toEqualPayload( - ['weeeelcome'], - ['index', 'index', 'input', 'groups'], - ); - }); - - it("should return false when there's no match", () => { - const result = blockService.matchText( - 'Goodbye Mr black', - blockGetStarted, - ); - expect(result).toEqual(false); - }); - - it('should return false when matching message against a block with no patterns', () => { - const result = blockService.matchText('Hello', blockEmpty); - expect(result).toEqual(false); - }); - }); - - describe('processMessage', () => { - // generic inputs we re-use - const ctx: Context = { - vars: { - phone: '+1123456789', - }, - user_location: { - address: undefined, - lat: 0, - lon: 0, - }, - user: { id: 'user-id', first_name: 'Jhon', last_name: 'Doe' } as any, - skip: {}, - attempt: 0, - }; // Context - const subCtx: Subscriber['context'] = { - vars: { - color: 'green', - }, - }; // SubscriberContext - const conversationId = 'conv-id'; - - it('should return a text envelope when the block is a text block', async () => { - const block = makeMockBlock({ - message: [ - 'Hello {{context.user.first_name}}, your phone is {{context.vars.phone}} and your favorite color is {{context.vars.color}}', - ], - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - false, - conversationId, - ); - - expect(env).toEqual({ - format: OutgoingMessageFormat.text, - message: { - text: 'Hello Jhon, your phone is +1123456789 and your favorite color is green', - }, - }); - }); - - it('should return a text envelope when the block is a text block (local fallback)', async () => { - const block = makeMockBlock({ - message: ['Hello world!'], - options: { - fallback: { - active: true, - max_attempts: 1, - message: ['Local fallback message ...'], - }, - }, - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - true, - conversationId, - ); - - expect(env).toEqual({ - format: OutgoingMessageFormat.text, - message: { - text: 'Local fallback message ...', - }, - }); - }); - - it('should return a quick replies envelope when the block message has quickReplies', async () => { - const block = makeMockBlock({ - message: { - text: '{{context.user.first_name}}, is this your phone number? {{context.vars.phone}}', - quickReplies: [ - { content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' }, - { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, - ], - }, - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - false, - conversationId, - ); - - expect(env).toEqual({ - format: OutgoingMessageFormat.quickReplies, - message: { - text: 'Jhon, is this your phone number? +1123456789', - quickReplies: [ - { content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' }, - { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, - ], - }, - }); - }); - - it('should return a quick replies envelope when the block message has quickReplies (local fallback)', async () => { - const block = makeMockBlock({ - message: { - text: '{{context.user.first_name}}, are you there?', - quickReplies: [ - { content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' }, - { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, - ], - }, - options: { - fallback: { - active: true, - max_attempts: 1, - message: ['Local fallback message ...'], - }, - }, - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - true, - conversationId, - ); - - expect(env).toEqual({ - format: OutgoingMessageFormat.quickReplies, - message: { - text: 'Local fallback message ...', - quickReplies: [ - { content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' }, - { content_type: QuickReplyType.text, title: 'No', payload: 'NO' }, - ], - }, - }); - }); - - it('should return a buttons envelope when the block message has buttons', async () => { - const block = makeMockBlock({ - message: { - text: '{{context.user.first_name}} {{context.user.last_name}}, what color do you like? {{context.vars.color}}?', - buttons: [ - { type: ButtonType.postback, title: 'Red', payload: 'RED' }, - { type: ButtonType.postback, title: 'Green', payload: 'GREEN' }, - ], - }, - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - false, - conversationId, - ); - - expect(env).toEqual({ - format: OutgoingMessageFormat.buttons, - message: { - text: 'Jhon Doe, what color do you like? green?', - buttons: [ - { - type: ButtonType.postback, - title: 'Red', - payload: 'RED', - }, - { - type: ButtonType.postback, - title: 'Green', - payload: 'GREEN', - }, - ], - }, - }); - }); - - it('should return a buttons envelope when the block message has buttons (local fallback)', async () => { - const block = makeMockBlock({ - message: { - text: '{{context.user.first_name}} {{context.user.last_name}}, what color do you like? {{context.vars.color}}?', - buttons: [ - { type: ButtonType.postback, title: 'Red', payload: 'RED' }, - { type: ButtonType.postback, title: 'Green', payload: 'GREEN' }, - ], - }, - options: { - fallback: { - active: true, - max_attempts: 1, - message: ['Local fallback message ...'], - }, - }, - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - true, - conversationId, - ); - - expect(env).toEqual({ - format: OutgoingMessageFormat.buttons, - message: { - text: 'Local fallback message ...', - buttons: [ - { - type: ButtonType.postback, - title: 'Red', - payload: 'RED', - }, - { - type: ButtonType.postback, - title: 'Green', - payload: 'GREEN', - }, - ], - }, - }); - }); - - it('should return an attachment envelope when payload has an id', async () => { - const block = makeMockBlock({ - message: { - attachment: { - type: FileType.image, - payload: { id: 'ABC123' }, - }, - }, - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - false, - conversationId, - ); - - expect(env).toEqual({ - format: OutgoingMessageFormat.attachment, - message: { - attachment: { - type: 'image', - payload: { id: 'ABC123' }, - }, - }, - }); - }); - - it('should return an attachment envelope when payload has an id (local fallback)', async () => { - const block = makeMockBlock({ - message: { - attachment: { - type: FileType.image, - payload: { id: 'ABC123' }, - }, - quickReplies: [], - }, - options: { - fallback: { - active: true, - max_attempts: 1, - message: ['Local fallback ...'], - }, - }, - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - true, - conversationId, - ); - - expect(env).toEqual({ - format: OutgoingMessageFormat.text, - message: { - text: 'Local fallback ...', - }, - }); - }); - - it('should keep quickReplies when present in an attachment block', async () => { - const block = makeMockBlock({ - message: { - attachment: { - type: FileType.video, - payload: { id: 'VID42' }, - }, - quickReplies: [ - { - content_type: QuickReplyType.text, - title: 'Replay', - payload: 'REPLAY', - }, - { - content_type: QuickReplyType.text, - title: 'Next', - payload: 'NEXT', - }, - ], - }, - }); - const env = await blockService.processMessage( - block, - ctx, - subCtx, - false, - conversationId, - ); - expect(env).toEqual({ - format: OutgoingMessageFormat.attachment, - message: { - attachment: { - type: FileType.video, - payload: { - id: 'VID42', - }, - }, - quickReplies: [ - { - content_type: QuickReplyType.text, - title: 'Replay', - payload: 'REPLAY', - }, - { - content_type: QuickReplyType.text, - title: 'Next', - payload: 'NEXT', - }, - ], - }, - }); - }); - - it('should throw when attachment payload misses an id (remote URLs deprecated)', async () => { - const spyCheckDeprecated = jest - .spyOn(blockService as any, 'checkDeprecatedAttachmentUrl') - .mockImplementation(() => {}); - const block = makeMockBlock({ - message: { - attachment: { - type: FileType.image, - payload: { url: 'https://example.com/old-way.png' }, // no "id" - }, - }, - }); - - await expect( - blockService.processMessage(block, ctx, subCtx, false, conversationId), - ).rejects.toThrow( - 'Remote attachments in blocks are no longer supported!', - ); - - expect(spyCheckDeprecated).toHaveBeenCalledTimes(1); - - spyCheckDeprecated.mockRestore(); - }); - - it('should process list message (with limit = 2 and skip = 0)', async () => { - const contentType = (await contentTypeService.findOne({ - where: { name: 'Product' }, - }))!; - blockProductListMock.options.content!.entity = contentType.id; - const result = await blockService.processMessage( - blockProductListMock, - { - ...contextBlankInstance, - skip: { [blockProductListMock.id]: 0 }, - }, - subscriberContextBlankInstance, - false, - 'conv_id', - ); - const elements = await contentService.find({ - where: { - status: true, - contentType: { id: contentType.id }, - }, - skip: 0, - take: 2, - order: { createdAt: 'DESC' }, - }); - const flattenedElements = elements.map(ContentOrmEntity.toElement); - expect(result.format).toEqualPayload( - blockProductListMock.options.content!.display, - ); - expect( - (result.message as StdOutgoingListMessage).elements, - ).toEqualPayload(flattenedElements); - expect((result.message as StdOutgoingListMessage).options).toEqualPayload( - blockProductListMock.options.content!, - ); - expect( - (result.message as StdOutgoingListMessage).pagination, - ).toEqualPayload({ total: 4, skip: 0, limit: 2 }); - }); - - it('should process list message (with limit = 2 and skip = 2)', async () => { - const contentType = (await contentTypeService.findOne({ - where: { name: 'Product' }, - }))!; - blockProductListMock.options.content!.entity = contentType.id; - const result = await blockService.processMessage( - blockProductListMock, - { - ...contextBlankInstance, - skip: { [blockProductListMock.id]: 2 }, - }, - subscriberContextBlankInstance, - false, - 'conv_id', - ); - const elements = await contentService.find({ - where: { - status: true, - contentType: { id: contentType.id }, - }, - skip: 2, - take: 2, - order: { createdAt: 'DESC' }, - }); - const flattenedElements = elements.map(ContentOrmEntity.toElement); - expect(result.format).toEqual( - blockProductListMock.options.content?.display, - ); - expect((result.message as StdOutgoingListMessage).elements).toEqual( - flattenedElements, - ); - expect((result.message as StdOutgoingListMessage).options).toEqual( - blockProductListMock.options.content, - ); - expect((result.message as StdOutgoingListMessage).pagination).toEqual({ - total: 4, - skip: 2, - limit: 2, - }); - }); - }); - - describe('search', () => { - it('should forward search request to repository', async () => { - const result = await blockService.search( - 'hasNextBlocks', - 10, - category.id, - ); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0].name).toBe('hasNextBlocks'); - }); - - it('should use default limit when not specified', async () => { - const result = await blockService.search('hasNextBlocks'); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - // Verify it's using the default limit - }); - - it('should filter by category correctly', async () => { - const result = await blockService.search( - 'hasNextBlocks', - 10, - category.id, - ); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - result.forEach((block) => { - expect(block.category?.toString()).toBe(category.id); - }); - }); - - it('should return search results with scores', async () => { - const result = await blockService.search('hasNextBlocks', 10); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - result.forEach((block) => { - expect(block).toHaveProperty('score'); - expect(typeof block.score).toBe('number'); - expect(block.score).toBeGreaterThan(0); - }); - }); - - it('should handle empty search results', async () => { - const result = await blockService.search('nonexistentblockname', 10); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(0); - }); - - it('should handle repository search errors gracefully', async () => { - // Mock the repository to throw an error - jest - .spyOn(blockRepository, 'search') - .mockRejectedValue(new Error('Connection error')); - - await expect(blockService.search('test', 10)).rejects.toThrow( - 'Connection error', - ); - }); - }); -}); diff --git a/packages/api/src/chat/services/block.service.ts b/packages/api/src/chat/services/block.service.ts deleted file mode 100644 index a78dce864..000000000 --- a/packages/api/src/chat/services/block.service.ts +++ /dev/null @@ -1,813 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; - -import EventWrapper from '@/channel/lib/EventWrapper'; -import { ChannelName } from '@/channel/types'; -import { ContentService } from '@/cms/services/content.service'; -import { NLU } from '@/helper/types'; -import { I18nService } from '@/i18n/services/i18n.service'; -import { LanguageService } from '@/i18n/services/language.service'; -import { NlpService } from '@/nlp/services/nlp.service'; -import { PluginService } from '@/plugins/plugins.service'; -import { PluginType } from '@/plugins/types'; -import { SettingService } from '@/setting/services/setting.service'; -import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp'; -import { BaseOrmService } from '@/utils/generics/base-orm.service'; -import { getRandomElement } from '@/utils/helpers/safeRandom'; - -import { - DEFAULT_BLOCK_SEARCH_LIMIT, - getDefaultFallbackOptions, -} from '../constants/block'; -import { - Block, - BlockDtoConfig, - BlockFull, - BlockStub, - BlockTransformerDto, -} from '../dto/block.dto'; -import { Label } from '../dto/label.dto'; -import { Subscriber } from '../dto/subscriber.dto'; -import { BlockOrmEntity } from '../entities/block.entity'; -import { EnvelopeFactory } from '../helpers/envelope-factory'; -import { BlockRepository } from '../repositories/block.repository'; -import { Context } from '../types/context'; -import { - OutgoingMessageFormat, - StdOutgoingEnvelope, - StdOutgoingSystemEnvelope, -} from '../types/message'; -import { FallbackOptions } from '../types/options'; -import { NlpPattern, PayloadPattern } from '../types/pattern'; -import { Payload } from '../types/quick-reply'; -import { SubscriberContext } from '../types/subscriberContext'; - -@Injectable() -export class BlockService extends BaseOrmService< - BlockOrmEntity, - BlockTransformerDto, - BlockDtoConfig, - BlockRepository -> { - constructor( - readonly repository: BlockRepository, - private readonly contentService: ContentService, - private readonly settingService: SettingService, - private readonly pluginService: PluginService, - protected readonly i18n: I18nService, - protected readonly languageService: LanguageService, - protected readonly nlpService: NlpService, - ) { - super(repository); - } - - /** - * Full-text search for blocks. Searches for blocks matching the given query string. - * - * @param query - The search query to filter blocks. - * @param limit - The maximum number of results to return. Defaults to 50. - * @param category - (Optional) The category to filter the search results. - * @returns A promise that resolves to the search results. - */ - async search( - query: string, - limit = DEFAULT_BLOCK_SEARCH_LIMIT, - category?: string, - ) { - return await this.repository.search(query, limit, category); - } - - /** - * Checks if block is supported on the specified channel. - * - * @param block - The block - * @param channel - The name of the channel to filter blocks by. - * - * @returns Whether the block is supported on the given channel. - */ - isChannelSupported( - block: B, - channel: ChannelName, - ) { - return ( - !block.trigger_channels || - block.trigger_channels.length === 0 || - block.trigger_channels.includes(channel) - ); - } - - /** - * Checks if the block matches the subscriber labels, allowing for two scenarios: - * - Has no trigger labels (making it applicable to all subscribers), or - * - Contains at least one trigger label that matches a label from the provided list. - * - * @param block - The block to check. - * @param labels - The list of subscriber labels to match against. - * @returns True if the block matches the subscriber labels, false otherwise. - */ - matchesSubscriberLabels( - block: B, - subscriber?: Subscriber, - ) { - if (!subscriber || !subscriber.labels) { - return true; // No subscriber or labels to match against - } - - const triggerLabels = block.trigger_labels.map((l: string | Label) => - typeof l === 'string' ? l : l.id, - ); - - return ( - triggerLabels.length === 0 || - triggerLabels.some((l) => subscriber.labels.includes(l)) - ); - } - - /** - * Retrieves the configured NLU penalty factor from settings, or falls back to a default value. - * - * @returns The NLU penalty factor as a number. - */ - private async getPenaltyFactor(): Promise { - const settings = await this.settingService.getSettings(); - const nluPenaltyFactor = - settings.chatbot_settings?.default_nlu_penalty_factor; - - if (!nluPenaltyFactor) { - this.logger.warn( - `The NLU penalty factor has reverted to its default fallback value of: ${FALLBACK_DEFAULT_NLU_PENALTY_FACTOR}`, - ); - } - - return nluPenaltyFactor ?? FALLBACK_DEFAULT_NLU_PENALTY_FACTOR; - } - - /** - * Find a block whose patterns matches the received event - * - * @param filteredBlocks blocks Starting/Next blocks in the conversation flow - * @param event Received channel's message - * @param canHaveMultipleMatches Whether to allow multiple matches for the same event - * (eg. Yes/No question to which the answer is ambiguous "Sometimes yes, sometimes no") - * - * @returns The block that matches - */ - async match( - blocks: BlockFull[], - event: EventWrapper, - canHaveMultipleMatches = true, - ): Promise { - if (!blocks.length) { - return undefined; - } - - // Narrow the search space - const channelName = event.getHandler().getName(); - const sender = event.getSender(); - const candidates = blocks.filter( - (b) => - this.isChannelSupported(b, channelName) && - this.matchesSubscriberLabels(b, sender), - ); - - if (!candidates.length) { - return undefined; - } - - // Priority goes to block who target users with labels - const prioritizedCandidates = candidates.sort( - (a, b) => b.trigger_labels.length - a.trigger_labels.length, - ); - // Perform a payload match & pick last createdAt - const payload = event.getPayload(); - if (payload) { - const payloadMatches = prioritizedCandidates.filter((b) => { - return this.matchPayload(payload, b); - }); - if (payloadMatches.length > 1 && !canHaveMultipleMatches) { - // If the payload matches multiple blocks , - // we return undefined so that we trigger the local fallback - return undefined; - } else if (payloadMatches.length > 0) { - // If we have a payload match, we return the first one - // (which is the most recent one due to the sort) - // and we don't check for text or NLP matches - return payloadMatches[0]; - } - } - - // Perform a text match (Text or Quick reply) - const text = event.getText().trim(); - if (text) { - const textMatches = prioritizedCandidates.filter((b) => { - return this.matchText(text, b); - }); - - if (textMatches.length > 1 && !canHaveMultipleMatches) { - // If the text matches multiple blocks (especially regex), - // we return undefined so that we trigger the local fallback - return undefined; - } else if (textMatches.length > 0) { - return textMatches[0]; - } - } - - // Perform an NLP Match - const nlp = event.getNLP(); - if (nlp) { - const scoredEntities = await this.nlpService.computePredictionScore(nlp); - - if (scoredEntities.entities.length) { - const penaltyFactor = await this.getPenaltyFactor(); - - return this.matchBestNLP( - prioritizedCandidates, - scoredEntities, - penaltyFactor, - ); - } - } - - return undefined; - } - - /** - * Performs a payload pattern match for the provided block - * - * @param payload - The payload - * @param block - The block - * - * @returns The payload pattern if there's a match - */ - matchPayload( - payload: string | Payload, - block: BlockFull | Block, - ): PayloadPattern | undefined { - const payloadPatterns = block.patterns?.filter( - (p) => typeof p === 'object' && 'label' in p, - ) as PayloadPattern[]; - - return payloadPatterns.find((pt: PayloadPattern) => { - // Either button postback payload Or content payload (ex. BTN_TITLE:CONTENT_PAYLOAD) - return ( - (typeof payload === 'string' && - pt.value && - (pt.value === payload || payload.startsWith(pt.value + ':'))) || - // Or attachment postback (ex. Like location quick reply for example) - (typeof payload === 'object' && pt.type && pt.type === payload.type) - ); - }); - } - - /** - * Checks if the block has matching text/regex patterns - * - * @param text - The received text message - * @param block - The block to check against - * - * @returns False if no match, string/regex capture else - */ - matchText( - text: string, - block: Block | BlockFull, - ): (RegExpMatchArray | string)[] | false { - // Filter text patterns & Instanciate Regex patterns - const patterns = block.patterns?.map((pattern) => { - if ( - typeof pattern === 'string' && - pattern.endsWith('/') && - pattern.startsWith('/') - ) { - return new RegExp(pattern.slice(1, -1), 'i'); - } - - return pattern; - }); - - if (patterns?.length) - // Return first match - for (let i = 0; i < patterns.length; i++) { - const pattern = patterns[i]; - if (pattern instanceof RegExp) { - if (pattern.test(text)) { - const matches = text.match(pattern); - if (matches) { - if (matches.length >= 2) { - // Remove global match if needed - matches.shift(); - } - - return matches; - } - } - continue; - } else if ( - typeof pattern === 'object' && - 'label' in pattern && - text.trim().toLowerCase() === pattern.label.toLowerCase() - ) { - // Payload (quick reply) - return [text]; - } else if ( - typeof pattern === 'string' && - text.trim().toLowerCase() === pattern.toLowerCase() - ) { - // Equals - return [text]; - } - // @deprecated - // else if ( - // typeof pattern === 'string' && - // Soundex(text) === Soundex(pattern) - // ) { - // // Sound like - // return [text]; - // } - } - - // No match - return false; - } - - /** - * Performs an NLU pattern match based on the predicted entities and/or values - * - * @param nlp - Parsed NLP entities - * @param block - The block to test - * - * @returns The NLU patterns that matches the predicted entities - */ - getMatchingNluPatterns( - { entities }: E, - block: B, - ): NlpPattern[][] { - // No nlp entities to check against - if (entities.length === 0) { - return []; - } - - const nlpPatterns = block.patterns.filter((p) => { - return Array.isArray(p); - }) as NlpPattern[][]; - - // No nlp patterns found - if (nlpPatterns.length === 0) { - return []; - } - - // Filter NLP patterns match based on best guessed entities - return nlpPatterns.filter((patterns: NlpPattern[]) => { - return patterns.every((p: NlpPattern) => { - if (p.match === 'value') { - return entities.find((e) => { - return ( - e.entity === p.entity && - (e.value === p.value || e.canonicalValue === p.value) - ); - }); - } else if (p.match === 'entity') { - return entities.find((e) => { - return e.entity === p.entity; - }); - } else { - this.logger.warn('Unknown NLP match type', p); - - return false; - } - }); - }); - } - - /** - * Finds and returns the block that best matches the given scored NLU entities. - * - * This function evaluates each block by matching its NLP patterns against the provided - * `scoredEntities`, using `matchNLP` and `calculateNluPatternMatchScore` to compute - * a confidence score for each match. The block with the highest total pattern match score - * is returned. - * - * If no block yields a positive score, the function returns `undefined`. - * - * @param blocks - A list of blocks to evaluate, each potentially containing NLP patterns. - * @param scoredEntities - The scored NLU entities to use for pattern matching. - * - * @returns A promise that resolves to the block with the highest NLP match score, - * or `undefined` if no suitable match is found. - */ - matchBestNLP( - blocks: B[], - scoredEntities: NLU.ScoredEntities, - penaltyFactor: number, - ): B | undefined { - const bestMatch = blocks.reduce( - (bestMatch, block) => { - const matchedPatterns = this.getMatchingNluPatterns( - scoredEntities, - block, - ); - // Compute the score (Weighted sum = weight * confidence) - // for each of block NLU patterns - const score = matchedPatterns.reduce((maxScore, patterns) => { - const score = this.calculateNluPatternMatchScore( - patterns, - scoredEntities, - penaltyFactor, - ); - - return Math.max(maxScore, score); - }, 0); - - return score > bestMatch.score ? { block, score } : bestMatch; - }, - { block: undefined, score: 0 }, - ); - - return bestMatch.block; - } - - /** - * Calculates the total NLU pattern match score by summing the individual pattern scores - * for each pattern that matches a scored entity. - * - * For each pattern in the list, the function attempts to find a matching entity in the - * NLU prediction. If a match is found, the score is computed using `computePatternScore`, - * potentially applying a penalty if the match is generic (entity-only). - * - * This scoring mechanism allows the system to prioritize more precise matches and - * quantify the overall alignment between predicted NLU entities and predefined patterns. - * - * @param patterns - A list of patterns to evaluate against the NLU prediction. - * @param prediction - The scored entities resulting from NLU inference. - * - * @returns The total aggregated match score based on matched patterns and their computed scores. - */ - calculateNluPatternMatchScore( - patterns: NlpPattern[], - prediction: NLU.ScoredEntities, - penaltyFactor: number, - ): number { - if (!patterns.length || !prediction.entities.length) { - return 0; - } - - return patterns.reduce((score, pattern) => { - const matchedEntity: NLU.ScoredEntity | undefined = - prediction.entities.find((e) => this.matchesNluEntity(e, pattern)); - const patternScore = matchedEntity - ? this.computePatternScore(matchedEntity, pattern, penaltyFactor) - : 0; - - return score + patternScore; - }, 0); - } - - /** - * Checks if a given `ParseEntity` from the NLP model matches the specified pattern - * and if its value exists within the values provided in the cache for the specified entity. - * - * @param e - The `ParseEntity` object from the NLP model, containing information about the entity and its value. - * @param pattern - The `NlpPattern` object representing the entity and value pattern to be matched. - * @param entityData - The `NlpCacheMapValues` object containing cached data, including entity values and weight, for the entity being matched. - * - * @returns A boolean indicating whether the `ParseEntity` matches the pattern and entity data from the cache. - * - * - The function compares the entity type between the `ParseEntity` and the `NlpPattern`. - * - If the pattern's match type is not `'value'`, it checks if the entity's value is present in the cache's `values` array. - * - If the pattern's match type is `'value'`, it further ensures that the entity's value matches the specified value in the pattern. - * - Returns `true` if all conditions are met, otherwise `false`. - */ - private matchesNluEntity( - { entity, value, canonicalValue }: E, - pattern: NlpPattern, - ): boolean { - return ( - entity === pattern.entity && - (pattern.match !== 'value' || - value === pattern.value || - canonicalValue === pattern.value) - ); - } - - /** - * Computes a pattern score by applying a penalty factor based on the matching rule of the pattern. - * - * This scoring mechanism allows prioritization of more specific patterns (entity + value) over - * more generic ones (entity only). - * - * @param entity - The scored entity object containing the base score. - * @param pattern - The pattern definition to match against the entity. - * @param [penaltyFactor=0.95] - Optional penalty factor applied when the pattern only matches the entity (default is 0.95). - * - * @returns The final pattern score after applying any applicable penalty. - */ - private computePatternScore( - entity: NLU.ScoredEntity, - pattern: NlpPattern, - penaltyFactor: number = 0.95, - ): number { - if (!entity || !pattern) { - return 0; - } - - // In case the pattern matches the entity regardless of the value (any) - // we apply a penalty so that we prioritize other patterns where both entity and value matches - const penalty = pattern.match === 'entity' ? penaltyFactor : 1; - - return entity.score * penalty; - } - - /** - * Matches an outcome-based block from a list of available blocks - * based on the outcome of a system message. - * - * @param blocks - An array of blocks to search for a matching outcome. - * @param envelope - The system message envelope containing the outcome to match. - * - * @returns - Returns the first matching block if found, otherwise returns `undefined`. - */ - matchOutcome( - blocks: Block[], - event: EventWrapper, - envelope: StdOutgoingSystemEnvelope, - ) { - // Perform a filter to get the candidates blocks - const handlerName = event.getHandler().getName(); - const sender = event.getSender(); - const candidates = blocks.filter( - (b) => - this.isChannelSupported(b, handlerName) && - this.matchesSubscriberLabels(b, sender), - ); - - if (!candidates.length) { - return undefined; - } - - return candidates.find((b) => { - return b.patterns - .filter( - (p) => typeof p === 'object' && 'type' in p && p.type === 'outcome', - ) - .some((p: PayloadPattern) => - ['any', envelope.message.outcome].includes(p.value), - ); - }); - } - - /** - * Replaces tokens with their context variables 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 - */ - processTokenReplacements( - text: string, - context: Context, - subscriberContext: SubscriberContext, - settings: Settings, - ): string { - return EnvelopeFactory.compileHandlebarsTemplate( - text, - { - ...context, - vars: { - ...(subscriberContext?.vars || {}), - ...(context.vars || {}), - }, - }, - settings, - ); - } - - /** - * Translates and replaces tokens with context variables values - * - * @deprecated use EnvelopeFactory.processText() instead - * @param text - Text to process - * @param context - The context object - * - * @returns The text message translated and tokens being replaces with values - */ - processText( - text: string, - context: Context, - subscriberContext: SubscriberContext, - settings: Settings, - ): string { - const envelopeFactory = new EnvelopeFactory( - { - ...context, - vars: { - ...context.vars, - ...subscriberContext.vars, - }, - }, - settings, - this.i18n, - ); - - 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 getRandomElement(array); - } - - /** - * Logs a warning message - */ - checkDeprecatedAttachmentUrl(block: Block | BlockFull) { - if ( - block.message && - 'attachment' in block.message && - block.message.attachment.payload && - 'url' in block.message.attachment.payload - ) { - this.logger.error( - 'Attachment Block : `url` payload has been deprecated in favor of `id`', - block.id, - block.message, - ); - } - } - - /** - * Processes a block message based on the format. - * - * @param block - The block holding the message to process - * @param context - Context object - * @param isLocalFallback - Whenever to process main message or local fallback message - * @param conversationId - The conversation ID - * - * @returns - Envelope containing message format and content following {format, message} object structure - */ - async processMessage( - block: Block | BlockFull, - context: Context, - subscriberContext: SubscriberContext, - isLocalFallback = false, - conversationId?: string, - ): Promise { - const settings = await this.settingService.getSettings(); - const envelopeFactory = new EnvelopeFactory( - { - ...context, - vars: { - ...context.vars, - ...subscriberContext.vars, - }, - }, - settings, - this.i18n, - ); - const fallback = isLocalFallback ? block.options?.fallback : undefined; - - if (Array.isArray(block.message)) { - // Text Message - return envelopeFactory.buildTextEnvelope( - fallback ? fallback.message : block.message, - ); - } else if ('text' in block.message) { - if ( - 'quickReplies' in block.message && - Array.isArray(block.message.quickReplies) && - block.message.quickReplies.length > 0 - ) { - return envelopeFactory.buildQuickRepliesEnvelope( - fallback ? fallback.message : block.message.text, - block.message.quickReplies, - ); - } else if ( - 'buttons' in block.message && - Array.isArray(block.message.buttons) && - block.message.buttons.length > 0 - ) { - return envelopeFactory.buildButtonsEnvelope( - fallback ? fallback.message : block.message.text, - block.message.buttons, - ); - } - } else if ('attachment' in block.message) { - const attachmentPayload = block.message.attachment.payload; - if (!('id' in attachmentPayload)) { - this.checkDeprecatedAttachmentUrl(block); - throw new Error( - 'Remote attachments in blocks are no longer supported!', - ); - } - const quickReplies = block.message.quickReplies - ? [...block.message.quickReplies] - : []; - - if (fallback) { - return quickReplies.length > 0 - ? envelopeFactory.buildQuickRepliesEnvelope( - fallback.message, - quickReplies, - ) - : envelopeFactory.buildTextEnvelope(fallback.message); - } - - return envelopeFactory.buildAttachmentEnvelope( - { - type: block.message.attachment.type, - payload: block.message.attachment.payload, - }, - quickReplies, - ); - } else if ( - block.message && - 'elements' in block.message && - block.options?.content - ) { - const contentBlockOptions = block.options.content; - // Hadnle pagination for list/carousel - let skip = 0; - if ( - contentBlockOptions.display === OutgoingMessageFormat.list || - contentBlockOptions.display === OutgoingMessageFormat.carousel - ) { - skip = - context.skip && context.skip[block.id] ? context.skip[block.id] : 0; - } - // Populate list with content - try { - const { elements, pagination } = await this.contentService.getContent( - contentBlockOptions, - skip, - ); - - return fallback - ? envelopeFactory.buildTextEnvelope(fallback.message) - : envelopeFactory.buildListEnvelope( - contentBlockOptions.display as - | OutgoingMessageFormat.list - | OutgoingMessageFormat.carousel, - contentBlockOptions, - elements, - pagination, - ); - } catch (err) { - this.logger.error( - 'Unable to retrieve content for list template process', - err, - ); - throw err; - } - } else if (block.message && 'plugin' in block.message) { - if (fallback) { - return envelopeFactory.buildTextEnvelope(fallback.message); - } - - const plugin = this.pluginService.findPlugin( - PluginType.block, - block.message.plugin, - ); - // Process custom plugin block - try { - const envelope = await plugin?.process(block, context, conversationId); - - if (!envelope) { - throw new Error('Unable to find envelope'); - } - - return envelope; - } catch (e) { - this.logger.error('Plugin was unable to load/process ', e); - throw new Error(`Plugin Error - ${JSON.stringify(block.message)}`); - } - } - throw new Error('Invalid message format.'); - } - - /** - * Retrieves the fallback options for a block. - * - * @param block - The block to retrieve fallback options from. - * @returns The fallback options for the block, or default options if not specified. - */ - getFallbackOptions(block: T | null): FallbackOptions { - return block?.options?.fallback ?? getDefaultFallbackOptions(); - } -} diff --git a/packages/api/src/chat/services/bot.service.spec.ts b/packages/api/src/chat/services/bot.service.spec.ts deleted file mode 100644 index 94b6ecd4a..000000000 --- a/packages/api/src/chat/services/bot.service.spec.ts +++ /dev/null @@ -1,660 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { TestingModule } from '@nestjs/testing'; - -import { webEventText } from '@/extensions/channels/web/__test__/events.mock'; -import WebChannelHandler from '@/extensions/channels/web/index.channel'; -import { WEB_CHANNEL_NAME } from '@/extensions/channels/web/settings'; -import WebEventWrapper from '@/extensions/channels/web/wrapper'; -import { HelperService } from '@/helper/helper.service'; -import { installBlockFixturesTypeOrm } from '@/utils/test/fixtures/block'; -import { installContentFixturesTypeOrm } from '@/utils/test/fixtures/content'; -import { installContextVarFixturesTypeOrm } from '@/utils/test/fixtures/contextvar'; -import { installConversationFixturesTypeOrm } from '@/utils/test/fixtures/conversation'; -import { installNlpSampleEntityFixturesTypeOrm } from '@/utils/test/fixtures/nlpsampleentity'; -import { installSettingFixturesTypeOrm } from '@/utils/test/fixtures/setting'; -import { installSubscriberFixturesTypeOrm } from '@/utils/test/fixtures/subscriber'; -import { - buttonsBlock, - mockWebChannelData, - quickRepliesBlock, - textBlock, -} from '@/utils/test/mocks/block'; -import { conversationGetStarted } from '@/utils/test/mocks/conversation'; -import { I18nServiceProvider } from '@/utils/test/providers/i18n-service.provider'; -import { closeTypeOrmConnections } from '@/utils/test/test'; -import { buildTestingMocks } from '@/utils/test/utils'; - -import { BlockFull } from '../dto/block.dto'; -import { Conversation, ConversationFull } from '../dto/conversation.dto'; - -import { BlockService } from './block.service'; -import { BotService } from './bot.service'; -import { ConversationService } from './conversation.service'; -import { SubscriberService } from './subscriber.service'; - -describe('BotService', () => { - let module: TestingModule; - let blockService: BlockService; - let subscriberService: SubscriberService; - let conversationService: ConversationService; - let botService: BotService; - let handler: WebChannelHandler; - let handlerMock: jest.Mocked< - Pick - >; - let eventEmitter: EventEmitter2; - const helperServiceMock: jest.Mocked< - Pick - > = { - getDefaultHelper: jest.fn(), - }; - - beforeAll(async () => { - helperServiceMock.getDefaultHelper.mockImplementation( - async () => - ({ - canHandleFlowEscape: () => false, - addEntity: jest.fn(), - updateEntity: jest.fn(), - deleteEntity: jest.fn(), - }) as any, - ); - - const testing = await buildTestingMocks({ - autoInjectFrom: ['providers'], - providers: [ - BotService, - I18nServiceProvider, - { - provide: HelperService, - useValue: helperServiceMock, - }, - ], - typeorm: { - fixtures: [ - installSettingFixturesTypeOrm, - installSubscriberFixturesTypeOrm, - installBlockFixturesTypeOrm, - installContextVarFixturesTypeOrm, - installConversationFixturesTypeOrm, - installContentFixturesTypeOrm, - installNlpSampleEntityFixturesTypeOrm, - ], - }, - }); - - module = testing.module; - - [ - subscriberService, - conversationService, - botService, - blockService, - eventEmitter, - ] = await testing.getMocks([ - SubscriberService, - ConversationService, - BotService, - BlockService, - EventEmitter2, - ]); - - handlerMock = { - getName: jest.fn().mockReturnValue(WEB_CHANNEL_NAME), - sendMessage: jest.fn().mockResolvedValue({ mid: 'mock-mid' }), - }; - handler = handlerMock as unknown as WebChannelHandler; - }); - - afterEach(jest.resetAllMocks); - afterAll(async () => { - if (module) { - await module.close(); - } - await closeTypeOrmConnections(); - }); - describe('startConversation', () => { - afterAll(() => { - jest.restoreAllMocks(); - }); - - it('should start a conversation', async () => { - const triggeredEvents: any[] = []; - - eventEmitter.on('hook:stats:entry', (...args) => { - triggeredEvents.push(args); - }); - - const event = new WebEventWrapper( - handler, - webEventText, - mockWebChannelData, - ); - const [block] = await blockService.findAndPopulate({ - where: { name: 'hasNextBlocks' }, - }); - const webSubscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-web-1' }, - }))!; - - event.setSender(webSubscriber); - - let hasBotSpoken = false; - const clearMock = jest - .spyOn(botService, 'triggerBlock') - .mockImplementation( - ( - actualEvent: WebEventWrapper, - actualConversation: Conversation, - actualBlock: BlockFull, - isFallback: boolean, - ) => { - expect(actualConversation).toEqualPayload({ - sender: webSubscriber.id, - active: true, - next: [], - context: { - user: { - firstName: webSubscriber.firstName, - lastName: webSubscriber.lastName, - language: 'en', - id: webSubscriber.id, - }, - user_location: { - lat: 0, - lon: 0, - }, - skip: {}, - vars: {}, - nlp: null, - payload: null, - attempt: 0, - channel: 'web-channel', - text: webEventText.data.text, - }, - }); - expect(actualEvent).toEqual(event); - expect(actualBlock).toEqual(block); - expect(isFallback).toEqual(false); - hasBotSpoken = true; - }, - ); - - await botService.startConversation(event, block); - expect(hasBotSpoken).toEqual(true); - expect(triggeredEvents).toEqual([ - ['popular', 'hasNextBlocks'], - ['new_conversations', 'New conversations'], - ]); - clearMock.mockClear(); - }); - }); - - describe('processConversationMessage', () => { - afterAll(() => { - jest.restoreAllMocks(); - }); - - it('has no active conversation', async () => { - const triggeredEvents: any[] = []; - eventEmitter.on('hook:stats:entry', (...args) => { - triggeredEvents.push(args); - }); - const event = new WebEventWrapper( - handler, - webEventText, - mockWebChannelData, - ); - const webSubscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-web-2' }, - }))!; - event.setSender(webSubscriber); - const captured = await botService.processConversationMessage(event); - - expect(captured).toBe(false); - expect(triggeredEvents).toEqual([]); - }); - - it('should capture a conversation', async () => { - const triggeredEvents: any[] = []; - - eventEmitter.on('hook:stats:entry', (...args) => { - triggeredEvents.push(args); - }); - - const event = new WebEventWrapper( - handler, - webEventText, - mockWebChannelData, - ); - const webSubscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-web-1' }, - }))!; - event.setSender(webSubscriber); - - jest - .spyOn(botService, 'handleOngoingConversationMessage') - .mockImplementation(() => Promise.resolve(true)); - const captured = await botService.processConversationMessage(event); - expect(captured).toBe(true); - expect(triggeredEvents).toEqual([ - ['existing_conversations', 'Existing conversations'], - ]); - }); - }); - - describe('proceedToNextBlock', () => { - const mockEvent = new WebEventWrapper( - handler, - webEventText, - mockWebChannelData, - ); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - it('should emit stats and call triggerBlock, returning true on success and reset attempt if not fallback', async () => { - const mockConvo = { - ...conversationGetStarted, - id: 'convo1', - context: { attempt: 2 }, - next: [], - sender: 'user1', - active: true, - } as unknown as ConversationFull; - const next = { id: 'block1', name: 'Block 1' } as BlockFull; - const fallback = false; - - jest - .spyOn(conversationService, 'storeContextData') - .mockImplementation(() => { - return Promise.resolve(mockConvo as unknown as Conversation); - }); - - jest.spyOn(botService, 'triggerBlock').mockResolvedValue(undefined); - const emitSpy = jest.spyOn(eventEmitter, 'emit'); - const result = await botService.proceedToNextBlock( - mockConvo, - next, - mockEvent, - fallback, - ); - - expect(emitSpy).toHaveBeenCalledWith( - 'hook:stats:entry', - 'popular', - next.name, - ); - - expect(botService.triggerBlock).toHaveBeenCalledWith( - mockEvent, - expect.objectContaining({ id: 'convo1' }), - next, - fallback, - ); - expect(result).toBe(true); - expect(mockConvo.context.attempt).toBe(0); - }); - - it('should increment attempt if fallback is true', async () => { - const mockConvo = { - ...conversationGetStarted, - id: 'convo2', - context: { attempt: 1 }, - next: [], - sender: 'user2', - active: true, - } as unknown as ConversationFull; - const next = { id: 'block2', name: 'Block 2' } as any; - const fallback = true; - const result = await botService.proceedToNextBlock( - mockConvo, - next, - mockEvent, - fallback, - ); - - expect(mockConvo.context.attempt).toBe(2); - expect(result).toBe(true); - }); - - it('should handle errors and emit conversation:end, returning false', async () => { - const mockConvo = { - ...conversationGetStarted, - id: 'convo3', - context: { attempt: 1 }, - next: [], - sender: 'user3', - active: true, - } as unknown as ConversationFull; - const next = { id: 'block3', name: 'Block 3' } as any; - const fallback = false; - - jest - .spyOn(conversationService, 'storeContextData') - .mockRejectedValue(new Error('fail')); - - const emitSpy = jest.spyOn(eventEmitter, 'emit'); - const result = await botService.proceedToNextBlock( - mockConvo, - next, - mockEvent, - fallback, - ); - - expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', mockConvo); - expect(result).toBe(false); - }); - }); - - describe('handleOngoingConversationMessage', () => { - const mockConvo = { - ...conversationGetStarted, - id: 'convo1', - context: { ...conversationGetStarted.context, attempt: 0 }, - next: [{ id: 'block1' }], - current: { - ...conversationGetStarted.current, - id: 'block0', - options: { - ...conversationGetStarted.current.options, - fallback: { - active: true, - max_attempts: 2, - message: [], - }, - }, - }, - } as unknown as ConversationFull; - const mockEvent = new WebEventWrapper( - handler, - webEventText, - mockWebChannelData, - ); - - beforeAll(() => { - jest.clearAllMocks(); - }); - - afterAll(() => { - jest.clearAllMocks(); - }); - - it('should proceed to the matched next block', async () => { - const matchedBlock = { - ...textBlock, - id: 'block1', - name: 'Block 1', - } as BlockFull; - jest - .spyOn(blockService, 'findAndPopulate') - .mockResolvedValue([matchedBlock]); - jest.spyOn(blockService, 'match').mockResolvedValue(matchedBlock); - jest.spyOn(botService, 'proceedToNextBlock').mockResolvedValue(true); - - const result = await botService.handleOngoingConversationMessage( - mockConvo, - mockEvent, - ); - - expect(blockService.findAndPopulate).toHaveBeenCalled(); - expect(blockService.match).toHaveBeenCalled(); - expect(botService.proceedToNextBlock).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it('should proceed to fallback block if no match and fallback is allowed', async () => { - jest.spyOn(blockService, 'findAndPopulate').mockResolvedValue([]); - jest.spyOn(blockService, 'match').mockResolvedValue(undefined); - const proceedSpy = jest - .spyOn(botService, 'proceedToNextBlock') - .mockResolvedValue(true); - const result = await botService.handleOngoingConversationMessage( - mockConvo, - mockEvent, - ); - - expect(proceedSpy).toHaveBeenCalledWith( - mockConvo, - expect.objectContaining({ id: 'block0', nextBlocks: mockConvo.next }), - mockEvent, - true, - ); - expect(result).toBe(true); - }); - - it('should end conversation and return false if no match and fallback not allowed', async () => { - const mockConvoWithoutFallback = { - ...mockConvo, - current: { - ...mockConvo.current, - options: { - ...mockConvo.current.options, - fallback: { - active: false, - max_attempts: 2, - message: [], - }, - }, - }, - } as unknown as ConversationFull; - jest.spyOn(blockService, 'findAndPopulate').mockResolvedValue([]); - jest.spyOn(blockService, 'match').mockResolvedValue(undefined); - const emitSpy = jest.spyOn(eventEmitter, 'emit'); - const result = await botService.handleOngoingConversationMessage( - mockConvoWithoutFallback, - mockEvent, - ); - - expect(emitSpy).toHaveBeenCalledWith( - 'hook:conversation:end', - mockConvoWithoutFallback, - ); - expect(result).toBe(false); - }); - - it('should end conversation and throw if an error occurs', async () => { - jest - .spyOn(blockService, 'findAndPopulate') - .mockRejectedValue(new Error('fail')); - const emitSpy = jest.spyOn(eventEmitter, 'emit'); - - await expect( - botService.handleOngoingConversationMessage(mockConvo, mockEvent), - ).rejects.toThrow('fail'); - expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', mockConvo); - }); - }); - - describe('shouldAttemptLocalFallback', () => { - const mockEvent = new WebEventWrapper( - handler, - webEventText, - mockWebChannelData, - ); - - beforeAll(() => { - jest.resetAllMocks(); - }); - - afterAll(() => { - jest.resetAllMocks(); - }); - - it('should return true when fallback is active and max attempts not exceeded', () => { - const result = botService.shouldAttemptLocalFallback( - { - ...conversationGetStarted, - context: { ...conversationGetStarted.context, attempt: 1 }, - current: { - ...conversationGetStarted.current, - options: { - fallback: { - active: true, - max_attempts: 1, - message: ['Please pick an option.'], - }, - }, - }, - }, - mockEvent, - ); - expect(result).toBe(true); - }); - - it('should return true when fallback is active and max attempts not reached', () => { - const result = botService.shouldAttemptLocalFallback( - { - ...conversationGetStarted, - context: { ...conversationGetStarted.context, attempt: 1 }, - current: { - ...conversationGetStarted.current, - options: { - fallback: { - active: true, - max_attempts: 3, - message: ['Please pick an option.'], - }, - }, - }, - }, - mockEvent, - ); - expect(result).toBe(true); - }); - - it('should return false when fallback is not active', () => { - const result = botService.shouldAttemptLocalFallback( - { - ...conversationGetStarted, - context: { ...conversationGetStarted.context, attempt: 1 }, - current: { - ...conversationGetStarted.current, - options: { - fallback: { - active: false, - max_attempts: 0, - message: [], - }, - }, - }, - }, - mockEvent, - ); - expect(result).toBe(false); - }); - - it('should return false when max attempts reached', () => { - const result = botService.shouldAttemptLocalFallback( - { - ...conversationGetStarted, - context: { ...conversationGetStarted.context, attempt: 4 }, - current: { - ...conversationGetStarted.current, - options: { - fallback: { - active: true, - max_attempts: 3, - message: ['Please pick an option.'], - }, - }, - }, - }, - mockEvent, - ); - expect(result).toBe(false); - }); - - it('should return false when fallback options are missing', () => { - const result = botService.shouldAttemptLocalFallback( - { - ...conversationGetStarted, - current: { - ...conversationGetStarted.current, - options: {}, - }, - }, - mockEvent, - ); - - expect(result).toBe(false); - }); - }); - - describe('findNextMatchingBlock', () => { - const mockEvent = new WebEventWrapper( - handler, - webEventText, - mockWebChannelData, - ); - - beforeAll(() => { - jest.resetAllMocks(); - }); - - afterAll(() => { - jest.resetAllMocks(); - }); - - it('should return a matching block if one is found and fallback is not active', async () => { - jest.spyOn(blockService, 'match').mockResolvedValue(buttonsBlock); - - const result = await botService.findNextMatchingBlock( - { - ...conversationGetStarted, - current: { - ...conversationGetStarted.current, - options: { - fallback: { - active: false, - message: [], - max_attempts: 0, - }, - }, - }, - next: [quickRepliesBlock, buttonsBlock].map((b) => ({ - ...b, - trigger_labels: b.trigger_labels.map(({ id }) => id), - assign_labels: b.assign_labels.map(({ id }) => id), - nextBlocks: [], - attachedBlock: null, - category: null, - previousBlocks: undefined, - attachedToBlock: undefined, - })), - }, - mockEvent, - ); - expect(result).toBe(buttonsBlock); - }); - - it('should return undefined if no matching block is found', async () => { - jest.spyOn(blockService, 'match').mockResolvedValue(undefined); - - const result = await botService.findNextMatchingBlock( - { - ...conversationGetStarted, - current: { - ...conversationGetStarted.current, - options: { - fallback: { - active: true, - message: ['Please pick an option.'], - max_attempts: 1, - }, - }, - }, - }, - mockEvent, - ); - expect(result).toBeUndefined(); - }); - }); -}); diff --git a/packages/api/src/chat/services/bot.service.ts b/packages/api/src/chat/services/bot.service.ts deleted file mode 100644 index 9dcb3901b..000000000 --- a/packages/api/src/chat/services/bot.service.ts +++ /dev/null @@ -1,713 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { In } from 'typeorm'; - -import { BotStatsType } from '@/analytics/entities/bot-stats.entity'; -import EventWrapper from '@/channel/lib/EventWrapper'; -import { HelperService } from '@/helper/helper.service'; -import { FlowEscape, HelperType } from '@/helper/types'; -import { LoggerService } from '@/logger/logger.service'; -import { SettingService } from '@/setting/services/setting.service'; - -import { getDefaultConversationContext } from '../constants/conversation'; -import { BlockFull } from '../dto/block.dto'; -import { Conversation, ConversationFull } from '../dto/conversation.dto'; -import { MessageCreateDto } from '../dto/message.dto'; -import { Context } from '../types/context'; -import { - IncomingMessageType, - OutgoingMessageFormat, - StdOutgoingMessageEnvelope, -} from '../types/message'; -import { FallbackOptions } from '../types/options'; - -import { BlockService } from './block.service'; -import { ConversationService } from './conversation.service'; -import { SubscriberService } from './subscriber.service'; - -@Injectable() -export class BotService { - constructor( - private readonly eventEmitter: EventEmitter2, - private readonly logger: LoggerService, - private readonly blockService: BlockService, - private readonly conversationService: ConversationService, - private readonly subscriberService: SubscriberService, - private readonly settingService: SettingService, - private readonly helperService: HelperService, - ) {} - - /** - * Sends a message to the subscriber via the appropriate messaging channel and handles related events. - * - * @param envelope - The outgoing message envelope containing the bot's response. - * @param block - The content block containing the message and options to be sent. - * @param context - Optional. The conversation context object, containing relevant data for personalization. - * @param fallback - Optional. Boolean flag indicating if this is a fallback message when no appropriate response was found. - */ - async sendMessageToSubscriber( - envelope: StdOutgoingMessageEnvelope, - event: EventWrapper, - block: BlockFull, - context?: Context, - fallback?: boolean, - ) { - const options = block.options; - const recipient = event.getSender(); - // Send message through the right channel - this.logger.debug('Sending message ... ', event.getSenderForeignId()); - const response = await event - .getHandler() - .sendMessage(event, envelope, options, context); - - this.eventEmitter.emit( - 'hook:stats:entry', - BotStatsType.outgoing, - 'Outgoing', - ); - this.eventEmitter.emit( - 'hook:stats:entry', - BotStatsType.all_messages, - 'All Messages', - ); - - // Trigger sent message event - const sentMessage: MessageCreateDto = { - mid: response && 'mid' in response ? response.mid : '', - message: envelope.message, - recipient: recipient.id, - handover: !!(options && options.assignTo), - read: false, - delivery: false, - }; - await this.eventEmitter.emitAsync('hook:chatbot:sent', sentMessage, event); - - // analytics log block or local fallback - if (fallback) { - this.eventEmitter.emit( - 'hook:analytics:fallback-local', - block, - event, - context, - ); - } else { - this.eventEmitter.emit('hook:analytics:block', block, event, context); - } - - // Apply updates : Assign block labels to user - const blockLabels = (block.assign_labels || []).map(({ id }) => id); - const assignTo = block.options?.assignTo || null; - - let subscriber = event.getSender(); - - // Apply labels update (no-op if labels is empty) - if (blockLabels.length > 0) { - this.logger.debug('Assigning labels ', blockLabels); - - subscriber = await this.subscriberService.assignLabels( - subscriber, - blockLabels, - ); - } - - // 2) Apply handover (no-op if assignTo is null) - if (assignTo) { - subscriber = await this.subscriberService.handOver(subscriber, assignTo); - } - - event.setSender(subscriber); - } - - /** - * Processes and executes a block, handling its associated messages and flow logic. - * - * The function performs the following steps: - * 1. Retrieves the conversation context and recipient information. - * 2. Generates an outgoing message envelope from the block. - * 3. Sends the message to the subscriber unless it's a system message. - * 4. Handles block chaining: - * - If the block has an attached block, it recursively triggers the attached block. - * - If the block has multiple possible next blocks, it determines the next block based on the outcome of the system message. - * - If there are next blocks but no outcome-based matching, it updates the conversation state for the next steps. - * 5. If no further blocks exist, it ends the flow execution. - * - * @param event - The incoming message or action that initiated this response. - * @param convo - The current conversation context and flow. - * @param block - The content block to be processed and sent. - * @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found. - * - * @returns A promise that either continues or ends the flow execution based on the available blocks. - */ - async triggerBlock( - event: EventWrapper, - convo: Conversation, - block: BlockFull, - fallback: boolean = false, - ) { - try { - const context = convo.context || getDefaultConversationContext(); - const recipient = event.getSender(); - const envelope = await this.blockService.processMessage( - block, - context, - recipient?.context, - fallback, - convo.id, - ); - - if (envelope.format !== OutgoingMessageFormat.system) { - await this.sendMessageToSubscriber( - envelope, - event, - block, - context, - fallback, - ); - } - - if (block.attachedBlock) { - // Sequential messaging ? - try { - const attachedBlock = await this.blockService.findOneAndPopulate( - block.attachedBlock.id, - ); - if (!attachedBlock) { - throw new Error( - 'No attached block to be found with id ' + block.attachedBlock, - ); - } - - return await this.triggerBlock(event, convo, attachedBlock, fallback); - } catch (err) { - this.logger.error('Unable to retrieve attached block', err); - this.eventEmitter.emit('hook:conversation:end', convo); - } - } else if ( - Array.isArray(block.nextBlocks) && - block.nextBlocks.length > 0 - ) { - try { - if (envelope.format === OutgoingMessageFormat.system) { - // System message: Trigger the next block based on the outcome - this.logger.debug( - 'Matching the outcome against the next blocks ...', - convo.id, - ); - const match = this.blockService.matchOutcome( - block.nextBlocks, - event, - envelope, - ); - - if (match) { - const nextBlock = await this.blockService.findOneAndPopulate( - match.id, - ); - if (!nextBlock) { - throw new Error( - 'No attached block to be found with id ' + - block.attachedBlock, - ); - } - - await this.conversationService.storeContextData( - convo, - nextBlock, - event, - false, - ); - - return await this.triggerBlock(event, convo, nextBlock, fallback); - } else { - this.logger.warn( - 'Block outcome did not match any of the next blocks', - convo, - ); - this.eventEmitter.emit('hook:conversation:end', convo); - } - } else { - // Conversation continues : Go forward to next blocks - this.logger.debug('Conversation continues ...', convo.id); - const nextIds = block.nextBlocks.map(({ id }) => id); - await this.conversationService.updateOne(convo.id, { - current: block.id, - next: nextIds, - }); - } - } catch (err) { - this.logger.error('Unable to continue the flow', convo, err); - - return; - } - } else { - // We need to end the conversation in this case - this.logger.debug('No attached/next blocks to execute ...'); - this.eventEmitter.emit('hook:conversation:end', convo); - } - } catch (err) { - this.logger.error('Unable to process/send message.', err); - this.eventEmitter.emit('hook:conversation:end', convo); - } - } - - /** - * Handles advancing the conversation to the specified *next* block. - * - * @param convo - The current conversation object containing context and state. - * @param next - The next block to proceed to in the conversation flow. - * @param event - The incoming event that triggered the conversation flow. - * @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found. - * - * @returns A promise that resolves to a boolean indicating whether the next block was successfully triggered. - */ - async proceedToNextBlock( - convo: ConversationFull, - next: BlockFull, - event: EventWrapper, - fallback: boolean, - ): Promise { - // Increment stats about popular blocks - this.eventEmitter.emit('hook:stats:entry', BotStatsType.popular, next.name); - this.logger.debug( - `Proceeding to next block ${next.id} for conversation ${convo.id}`, - ); - - try { - convo.context.attempt = fallback ? convo.context.attempt + 1 : 0; - const updatedConversation = - await this.conversationService.storeContextData( - convo, - next, - event, - // If this is a local fallback then we don’t capture vars. - !fallback, - ); - - await this.triggerBlock(event, updatedConversation, next, fallback); - - return true; - } catch (err) { - this.logger.error('Unable to proceed to the next block!', err); - this.eventEmitter.emit('hook:conversation:end', convo); - - return false; - } - } - - /** - * Finds the next block that matches the event criteria within the conversation's next blocks. - * - * @param convo - The current conversation object containing context and state. - * @param event - The incoming event that triggered the conversation flow. - * - * @returns A promise that resolves with the matched block or undefined if no match is found. - */ - async findNextMatchingBlock( - convo: ConversationFull, - event: EventWrapper, - ): Promise { - const fallbackOptions: FallbackOptions = - this.blockService.getFallbackOptions(convo.current); - // We will avoid having multiple matches when we are not at the start of a conversation - // and only if local fallback is enabled - const canHaveMultipleMatches = !fallbackOptions?.active; - // Find the next block that matches - const nextBlocks = await this.blockService.findAndPopulate({ - where: { id: In(convo.next.map(({ id }) => id)) }, - }); - - return await this.blockService.match( - nextBlocks, - event, - canHaveMultipleMatches, - ); - } - - /** - * Determines if a fallback should be attempted based on the event type, fallback options, and conversation context. - * - * @param convo - The current conversation object containing context and state. - * @param event - The incoming event that triggered the conversation flow. - * - * @returns A boolean indicating whether a fallback should be attempted. - */ - shouldAttemptLocalFallback( - convo: ConversationFull, - event: EventWrapper, - ): boolean { - const fallbackOptions = this.blockService.getFallbackOptions(convo.current); - const maxAttempts = fallbackOptions?.max_attempts ?? 0; - - return ( - event.getMessageType() === IncomingMessageType.message && - !!fallbackOptions?.active && - maxAttempts > 0 && - convo.context.attempt <= maxAttempts - ); - } - - /** - * Processes and responds to an incoming message within an ongoing conversation flow. - * Determines the next block in the conversation, attempts to match the message with available blocks, - * and handles fallback scenarios if no match is found. - * - * @param convo - The current conversation object, representing the flow and context of the dialogue. - * @param event - The incoming message or action that triggered this response. - * - * @returns A promise that resolves with a boolean indicating whether the conversation is active and a matching block was found. - */ - async handleOngoingConversationMessage( - convo: ConversationFull, - event: EventWrapper, - ) { - try { - let fallback = false; - this.logger.debug('Handling ongoing conversation message ...', convo.id); - const matchedBlock = await this.findNextMatchingBlock(convo, event); - let fallbackBlock: BlockFull | undefined = undefined; - if (!matchedBlock && this.shouldAttemptLocalFallback(convo, event)) { - const fallbackResult = await this.handleFlowEscapeFallback( - convo, - event, - ); - fallbackBlock = fallbackResult.nextBlock; - fallback = fallbackResult.fallback; - } - - const next = matchedBlock || fallbackBlock; - - this.logger.debug('Responding ...', convo.id); - - if (next) { - // Proceed to the execution of the next block - return await this.proceedToNextBlock(convo, next, event, fallback); - } else { - // Conversation is still active, but there's no matching block to call next - // We'll end the conversation but this message is probably lost in time and space. - this.logger.debug('No matching block found to call next ', convo.id); - this.eventEmitter.emit('hook:conversation:end', convo); - - return false; - } - } catch (err) { - this.logger.error('Unable to populate the next blocks!', err); - this.eventEmitter.emit('hook:conversation:end', convo); - throw err; - } - } - - /** - * Handles the flow escape fallback logic for a conversation. - * - * This method adjudicates the flow escape event and helps determine the next block to execute based on the helper's response. - * It can coerce the event to a specific next block, create a new context, or reprompt the user with a fallback message. - * If the helper cannot handle the flow escape, it returns a fallback block with the current conversation's state. - * - * @param convo - The current conversation object. - * @param event - The incoming event that triggered the fallback. - * - * @returns An object containing the next block to execute (if any) and a flag indicating if a fallback should occur. - */ - async handleFlowEscapeFallback( - convo: ConversationFull, - event: EventWrapper, - ): Promise<{ nextBlock?: BlockFull; fallback: boolean }> { - const currentBlock = convo.current; - const fallbackOptions: FallbackOptions = - this.blockService.getFallbackOptions(currentBlock); - const fallbackBlock: BlockFull = { - ...currentBlock, - nextBlocks: convo.next, - assign_labels: [], - trigger_labels: [], - attachedBlock: null, - category: null, - previousBlocks: [], - }; - - try { - const helper = await this.helperService.getDefaultHelper( - HelperType.FLOW_ESCAPE, - ); - - if (!helper.canHandleFlowEscape(currentBlock)) { - return { nextBlock: fallbackBlock, fallback: true }; - } - - // Adjudicate the flow escape event - this.logger.debug( - `Adjudicating flow escape for block '${currentBlock.id}' in conversation '${convo.id}'.`, - ); - const result = await helper.adjudicate(event, currentBlock); - - switch (result.action) { - case FlowEscape.Action.COERCE: { - // Coerce the option to the next block - this.logger.debug(`Coercing option to the next block ...`, convo.id); - const proxiedEvent = new Proxy(event, { - get(target, prop, receiver) { - if (prop === 'getText') { - return () => result.coercedOption + ''; - } - - return Reflect.get(target, prop, receiver); - }, - }); - const matchedBlock = await this.findNextMatchingBlock( - convo, - proxiedEvent, - ); - - return { nextBlock: matchedBlock, fallback: false }; - } - - case FlowEscape.Action.NEW_CTX: - return { nextBlock: undefined, fallback: false }; - - case FlowEscape.Action.REPROMPT: - default: - if (result.repromptMessage) { - fallbackBlock.options.fallback = { - ...fallbackOptions, - message: [result.repromptMessage], - }; - } - - return { nextBlock: fallbackBlock, fallback: true }; - } - } catch (err) { - this.logger.warn( - 'Unable to handle flow escape, using default local fallback ...', - err, - ); - - return { nextBlock: fallbackBlock, fallback: true }; - } - } - - /** - * Determines if the incoming message belongs to an active conversation and processes it accordingly. - * If an active conversation is found, the message is handled as part of that conversation. - * - * @param event - The incoming message or action from the subscriber. - * - * @returns A promise that resolves with the conversation's response or false if no active conversation is found. - */ - async processConversationMessage(event: EventWrapper) { - this.logger.debug( - 'Is this message apart of an active conversation ? Searching ... ', - ); - const subscriber = event.getSender(); - try { - const conversation = await this.conversationService.findOneAndPopulate({ - where: { sender: { id: subscriber.id }, active: true }, - }); - // No active conversation found - if (!conversation) { - this.logger.debug('No active conversation found ', subscriber.id); - - return false; - } - - this.eventEmitter.emit( - 'hook:stats:entry', - BotStatsType.existing_conversations, - 'Existing conversations', - ); - this.logger.debug('Conversation has been captured! Responding ...'); - - return await this.handleOngoingConversationMessage(conversation, event); - } catch (err) { - this.logger.error( - 'An error occurred when searching for a conversation ', - err, - ); - - return null; - } - } - - /** - * Create a new conversation starting from a given block (entrypoint) - * - * @param event - Incoming message/action - * @param block - Starting block - */ - async startConversation(event: EventWrapper, block: BlockFull) { - // Increment popular stats - this.eventEmitter.emit( - 'hook:stats:entry', - BotStatsType.popular, - block.name, - ); - // Launching a new conversation - const subscriber = event.getSender(); - - try { - const convo = await this.conversationService.create({ - sender: subscriber.id, - }); - this.eventEmitter.emit( - 'hook:stats:entry', - BotStatsType.new_conversations, - 'New conversations', - ); - - try { - const updatedConversation = - await this.conversationService.storeContextData( - convo, - block, - event, - true, - ); - - this.logger.debug( - 'Started a new conversation with ', - subscriber.id, - block.name, - ); - - return await this.triggerBlock( - event, - updatedConversation, - block, - false, - ); - } catch (err) { - this.logger.error('Unable to store context data!', err); - this.eventEmitter.emit('hook:conversation:end', convo); - } - } catch (err) { - this.logger.error('Unable to start a new conversation with ', err); - } - } - - /** - * Return global fallback block - * - * @param settings - The app settings - * - * @returns The global fallback block - */ - async getGlobalFallbackBlock(settings: Settings) { - const chatbot_settings = settings.chatbot_settings; - if (chatbot_settings.fallback_block) { - const block = await this.blockService.findOneAndPopulate( - chatbot_settings.fallback_block, - ); - - if (!block) { - throw new Error('Unable to retrieve global fallback block.'); - } - - return block; - } - throw new Error('No global fallback block is defined.'); - } - - /** - * Processes incoming message event from a given channel - * - * @param event - Incoming message/action - */ - async handleMessageEvent(event: EventWrapper) { - const settings = await this.settingService.getSettings(); - try { - const captured = await this.processConversationMessage(event); - if (captured) { - return; - } - - // Search for entry blocks - try { - const blocks = await this.blockService.findAndPopulate({ - where: { starts_conversation: true }, - }); - - if (!blocks.length) { - this.logger.debug('No starting message blocks was found'); - } - - // Search for a block match - const block = await this.blockService.match(blocks, event); - - // No block match - if (!block) { - this.logger.debug('No message blocks available!'); - if ( - settings.chatbot_settings && - settings.chatbot_settings.global_fallback - ) { - this.eventEmitter.emit('hook:analytics:fallback-global', event); - this.logger.debug('Sending global fallback message ...'); - // If global fallback is defined in a block then launch a new conversation - // Otherwise, send a simple text message as defined in global settings - try { - const fallbackBlock = await this.getGlobalFallbackBlock(settings); - - return this.startConversation(event, fallbackBlock); - } catch (err) { - this.logger.warn( - 'No global fallback block defined, sending a message ...', - err, - ); - const globalFallbackBlock: BlockFull = { - id: 'global-fallback', - name: 'Global Fallback', - message: settings.chatbot_settings.fallback_message, - options: {}, - patterns: [], - assign_labels: [], - starts_conversation: false, - position: { x: 0, y: 0 }, - capture_vars: [], - builtin: true, - createdAt: new Date(), - updatedAt: new Date(), - attachedBlock: null, - trigger_labels: [], - nextBlocks: [], - category: null, - outcomes: [], - trigger_channels: [], - }; - const envelope = await this.blockService.processMessage( - globalFallbackBlock, - getDefaultConversationContext(), - { vars: {} }, // @TODO: use subscriber ctx - ); - - await this.sendMessageToSubscriber( - envelope as StdOutgoingMessageEnvelope, - event, - globalFallbackBlock, - ); - } - } - - // Do nothing ... - return; - } - - this.startConversation(event, block); - } catch (err) { - this.logger.error( - 'An error occurred while retrieving starting message blocks ', - err, - ); - } - } catch (err) { - this.logger.debug( - 'Either something went wrong, no active conservation was found or user changed subject', - err, - ); - } - } -} diff --git a/packages/api/src/chat/services/category.service.ts b/packages/api/src/chat/services/category.service.ts deleted file mode 100644 index eb41214c8..000000000 --- a/packages/api/src/chat/services/category.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; - -import { BaseOrmService } from '@/utils/generics/base-orm.service'; - -import { CategoryDtoConfig, CategoryTransformerDto } from '../dto/category.dto'; -import { CategoryOrmEntity } from '../entities/category.entity'; -import { CategoryRepository } from '../repositories/category.repository'; - -@Injectable() -export class CategoryService extends BaseOrmService< - CategoryOrmEntity, - CategoryTransformerDto, - CategoryDtoConfig -> { - constructor(readonly repository: CategoryRepository) { - super(repository); - } -} diff --git a/packages/api/src/chat/services/chat.service.ts b/packages/api/src/chat/services/chat.service.ts index 633cb8d33..b9bb2530b 100644 --- a/packages/api/src/chat/services/chat.service.ts +++ b/packages/api/src/chat/services/chat.service.ts @@ -11,19 +11,14 @@ import { In, InsertEvent, UpdateEvent } from 'typeorm'; import { BotStatsType } from '@/analytics/entities/bot-stats.entity'; import EventWrapper from '@/channel/lib/EventWrapper'; import { config } from '@/config'; -import { HelperService } from '@/helper/helper.service'; -import { HelperType } from '@/helper/types'; -import { LanguageService } from '@/i18n/services/language.service'; import { LoggerService } from '@/logger/logger.service'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; +import { AgenticService } from '@/workflow/services/agentic.service'; -import { Conversation } from '../dto/conversation.dto'; import { MessageCreateDto } from '../dto/message.dto'; import { SubscriberOrmEntity } from '../entities/subscriber.entity'; import { OutgoingMessage } from '../types/message'; -import { BotService } from './bot.service'; -import { ConversationService } from './conversation.service'; import { MessageService } from './message.service'; import { SubscriberService } from './subscriber.service'; @@ -32,45 +27,12 @@ export class ChatService { constructor( private readonly eventEmitter: EventEmitter2, private readonly logger: LoggerService, - private readonly conversationService: ConversationService, private readonly messageService: MessageService, private readonly subscriberService: SubscriberService, - private readonly botService: BotService, + private readonly agenticService: AgenticService, private readonly websocketGateway: WebsocketGateway, - private readonly helperService: HelperService, - private readonly languageService: LanguageService, ) {} - /** - * Ends a given conversation (sets active to false) - * - * @param convo - The conversation to end - */ - @OnEvent('hook:conversation:end') - async handleEndConversation(convo: Conversation) { - try { - await this.conversationService.end(convo); - this.logger.debug('Conversation has ended successfully.', convo.id); - } catch (err) { - this.logger.error('Unable to end conversation !', convo.id, err); - } - } - - /** - * Ends a given conversation (sets active to false) - * - * @param convoId - The conversation ID - */ - @OnEvent('hook:conversation:close') - async handleCloseConversation(convoId: string) { - try { - await this.conversationService.deleteOne(convoId); - this.logger.debug('Conversation is closed successfully.', convoId); - } catch (err) { - this.logger.error('Unable to close conversation.', err); - } - } - /** * Finds or creates a message and broadcast it to the websocket "Message" room * @@ -296,57 +258,21 @@ export class ChatService { await event.preprocess(); } - await this.enrichEventWithNLU(event); - // Trigger message received event this.eventEmitter.emit('hook:chatbot:received', event); if (subscriber?.assignedTo) { - this.logger.debug('Conversation taken over', subscriber.assignedTo); + this.logger.debug('Chat taken over', subscriber.assignedTo); return; } - this.botService.handleMessageEvent(event); + await this.agenticService.handleMessageEvent(event); } catch (err) { this.logger.error('Error handling new message', err); } } - /** - * Enriches an incoming event by performing NLP inference and updating the sender's language profile if detected. - * - * @param event - The incoming event object containing user input and metadata. - * @returns Resolves when preprocessing is complete. Any errors are logged without throwing. - */ - async enrichEventWithNLU(event: EventWrapper) { - if (!event.getText() || event.getNLP()) { - return; - } - - try { - const helper = await this.helperService.getDefaultHelper(HelperType.NLU); - const nlp = await helper.predict(event.getText(), true); - - // Check & catch user language through NLP - if (nlp) { - const languages = await this.languageService.getLanguages(); - const spokenLanguage = nlp.entities.find( - (e) => e.entity === 'language', - ); - if (spokenLanguage && spokenLanguage.value in languages) { - const profile = event.getSender(); - profile.language = spokenLanguage.value; - event.setSender(profile); - } - } - - event.setNLP(nlp); - } catch (err) { - this.logger.error('Unable to perform NLP parse', err); - } - } - /** * Handle new subscriber and send notification the websocket * diff --git a/packages/api/src/chat/services/context-var.service.ts b/packages/api/src/chat/services/context-var.service.ts index c418b3827..6b2bd073d 100644 --- a/packages/api/src/chat/services/context-var.service.ts +++ b/packages/api/src/chat/services/context-var.service.ts @@ -5,13 +5,10 @@ */ import { Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; import { BaseOrmService } from '@/utils/generics/base-orm.service'; -import { Block, BlockFull } from '../dto/block.dto'; import { - ContextVar, ContextVarDtoConfig, ContextVarTransformerDto, } from '../dto/context-var.dto'; @@ -27,31 +24,4 @@ export class ContextVarService extends BaseOrmService< constructor(readonly repository: ContextVarRepository) { super(repository); } - - /** - * Retrieves a mapping of context variable names to their corresponding `ContextVar` objects for a given block. - * - * @param {Block | BlockFull} block - The block containing the capture variables to retrieve context variables for. - * @returns {Promise>} A promise that resolves to a record mapping context variable names to `ContextVar` objects. - */ - async getContextVarsByBlock( - block: Block | BlockFull, - ): Promise> { - const captureVarNames = - block.capture_vars?.map(({ context_var }) => context_var) ?? []; - - if (!captureVarNames.length) { - return {}; - } - - const vars = await this.find({ - where: { name: In(captureVarNames) }, - }); - - return vars.reduce>((acc, cv) => { - acc[cv.name] = cv; - - return acc; - }, {}); - } } diff --git a/packages/api/src/chat/services/conversation.service.spec.ts b/packages/api/src/chat/services/conversation.service.spec.ts deleted file mode 100644 index f7b483a2f..000000000 --- a/packages/api/src/chat/services/conversation.service.spec.ts +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { TestingModule } from '@nestjs/testing'; - -import { AttachmentService } from '@/attachment/services/attachment.service'; -import EventWrapper from '@/channel/lib/EventWrapper'; -import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants'; -import { ConversationService } from '@/chat/services/conversation.service'; -import { SubscriberService } from '@/chat/services/subscriber.service'; -import { OutgoingMessageFormat } from '@/chat/types/message'; -import { installContextVarFixturesTypeOrm } from '@/utils/test/fixtures/contextvar'; -import { installConversationFixturesTypeOrm } from '@/utils/test/fixtures/conversation'; -import { closeTypeOrmConnections } from '@/utils/test/test'; -import { buildTestingMocks } from '@/utils/test/utils'; -import { WebsocketGateway } from '@/websocket/websocket.gateway'; - -import { Block } from '../dto/block.dto'; - -describe('ConversationService (TypeORM)', () => { - let module: TestingModule; - let conversationService: ConversationService; - let subscriberService: SubscriberService; - - const attachmentServiceMock: jest.Mocked> = { - store: jest.fn(), - }; - const gatewayMock: jest.Mocked< - Pick - > = { - joinNotificationSockets: jest.fn(), - }; - - beforeAll(async () => { - const testing = await buildTestingMocks({ - autoInjectFrom: ['providers'], - providers: [ - ConversationService, - { provide: AttachmentService, useValue: attachmentServiceMock }, - { provide: WebsocketGateway, useValue: gatewayMock }, - ], - typeorm: { - fixtures: [ - installContextVarFixturesTypeOrm, - installConversationFixturesTypeOrm, - ], - }, - }); - - module = testing.module; - [conversationService, subscriberService] = await testing.getMocks([ - ConversationService, - SubscriberService, - ]); - }); - - afterEach(jest.clearAllMocks); - - afterAll(async () => { - if (module) { - await module.close(); - } - await closeTypeOrmConnections(); - }); - - describe('ConversationService.storeContextData', () => { - it('should enrich the conversation context and persist conversation + subscriber (permanent)', async () => { - const subscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-messenger' }, - }))!; - const conversation = (await conversationService.findOne({ - where: { sender: { id: subscriber.id } }, - }))!; - const next = { - id: 'block-1', - capture_vars: [{ entity: -1, context_var: 'phone' }], - } as Block; - const mockPhone = '+1 514 678 9873'; - const event = { - getMessageType: jest.fn().mockReturnValue('message'), - getText: jest.fn().mockReturnValue(mockPhone), - getPayload: jest.fn().mockReturnValue(undefined), - getNLP: jest.fn().mockReturnValue(undefined), - getMessage: jest.fn().mockReturnValue({ - text: mockPhone, - }), - getHandler: jest.fn().mockReturnValue({ - getName: jest.fn().mockReturnValue('messenger-channel'), - }), - getSender: jest.fn().mockReturnValue({ - id: subscriber.id, - firstName: subscriber.firstName, - lastName: subscriber.lastName, - language: subscriber.language, - context: { - vars: { - email: 'john.doe@mail.com', - }, - }, - }), - setSender: jest.fn(), - } as unknown as EventWrapper; - const result = await conversationService.storeContextData( - conversation, - next, - event, - true, - ); - - expect(result.context.channel).toBe('messenger-channel'); - expect(result.context.text).toBe(mockPhone); - expect(result.context.vars.phone).toBe(mockPhone); - expect(result.context.user).toEqual( - expect.objectContaining({ - id: subscriber.id, - firstName: subscriber.firstName, - lastName: subscriber.lastName, - language: subscriber.language, - }), - ); - - const updatedSubscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-messenger' }, - }))!; - - expect(updatedSubscriber.context.vars?.phone).toBe(mockPhone); - - // expect(event.setSender).toHaveBeenCalledWith(updatedSubscriber); - }); - - it('should capture an NLP entity value into context vars (non-permanent)', async () => { - const subscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-messenger' }, - }))!; - const conversation = (await conversationService.findOne({ - where: { sender: { id: subscriber.id } }, - }))!; - const next = { - id: 'block-1', - capture_vars: [{ entity: 'country_code', context_var: 'country' }], - } as Block; - const mockMessage = 'Are you from the US?'; - const event = { - getMessageType: jest.fn().mockReturnValue('message'), - getText: jest.fn().mockReturnValue(mockMessage), - getPayload: jest.fn().mockReturnValue(undefined), - getNLP: jest.fn().mockReturnValue({ - entities: [ - { - entity: 'country_code', - value: 'US', - }, - ], - }), - getMessage: jest.fn().mockReturnValue({ - text: mockMessage, - }), - getHandler: jest.fn().mockReturnValue({ - getName: jest.fn().mockReturnValue('messenger-channel'), - }), - getSender: jest.fn().mockReturnValue({ - id: subscriber.id, - firstName: subscriber.firstName, - lastName: subscriber.lastName, - language: subscriber.language, - context: { - vars: { - email: 'john.doe@mail.com', - }, - }, - }), - setSender: jest.fn(), - } as unknown as EventWrapper; - const result = await conversationService.storeContextData( - conversation, - next, - event, - true, - ); - - expect(result.context.vars.country).toBe('US'); - const updatedSubscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-messenger' }, - }))!; - expect(updatedSubscriber.context.vars?.country).toBe(undefined); - }); - - it('should capture user coordinates when message type is "location"', async () => { - const subscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-messenger' }, - }))!; - const conversation = (await conversationService.findOne({ - where: { sender: { id: subscriber.id } }, - }))!; - const next = { - id: 'block-1', - capture_vars: [{ entity: 'country_code', context_var: 'country' }], - } as Block; - const event = { - getMessageType: jest.fn().mockReturnValue('location'), - getText: jest.fn().mockReturnValue(''), - getPayload: jest.fn().mockReturnValue(undefined), - getNLP: jest.fn(), - getMessage: jest.fn().mockReturnValue({ - coordinates: { lat: 36.8065, lon: 10.1815 }, - }), - getHandler: jest.fn().mockReturnValue({ - getName: jest.fn().mockReturnValue('messenger-channel'), - }), - getSender: jest.fn().mockReturnValue({ - id: subscriber.id, - firstName: subscriber.firstName, - lastName: subscriber.lastName, - language: subscriber.language, - context: { - vars: { - email: 'john.doe@mail.com', - }, - }, - }), - setSender: jest.fn(), - } as unknown as EventWrapper; - const result = await conversationService.storeContextData( - conversation, - next, - event, - ); - - expect(result.context.user_location).toEqual({ - lat: 36.8065, - lon: 10.1815, - }); - }); - - it('should increment skip when VIEW_MORE payload is received for list/carousel blocks', async () => { - const subscriber = (await subscriberService.findOne({ - where: { foreignId: 'foreign-id-messenger' }, - }))!; - const conversation = (await conversationService.findOne({ - where: { sender: { id: subscriber.id } }, - }))!; - const next = { - id: 'block-1', - capture_vars: [], - options: { - content: { - display: OutgoingMessageFormat.list, - limit: 10, - }, - }, - } as unknown as Block; - const event = { - getMessageType: jest.fn().mockReturnValue('message'), - getText: jest.fn().mockReturnValue('I would like to see the products'), - getPayload: jest.fn().mockReturnValue(undefined), - getNLP: jest.fn(), - getMessage: jest.fn().mockReturnValue({ - text: 'I would like to see the products', - }), - getHandler: jest.fn().mockReturnValue({ - getName: jest.fn().mockReturnValue('messenger-channel'), - }), - getSender: jest.fn().mockReturnValue({ - id: subscriber.id, - firstName: subscriber.firstName, - lastName: subscriber.lastName, - language: subscriber.language, - context: { - vars: { - email: 'john.doe@mail.com', - }, - }, - }), - setSender: jest.fn(), - } as unknown as EventWrapper; - const result1 = await conversationService.storeContextData( - conversation, - next, - event, - ); - - expect(result1.context.skip['block-1']).toBe(0); - - const event2 = { - getMessageType: jest.fn().mockReturnValue('postback'), - getText: jest.fn().mockReturnValue('View more'), - getPayload: jest.fn().mockReturnValue(VIEW_MORE_PAYLOAD), - getNLP: jest.fn(), - getMessage: jest.fn().mockReturnValue({ - coordinates: { lat: 36.8065, lon: 10.1815 }, - }), - getHandler: jest.fn().mockReturnValue({ - getName: jest.fn().mockReturnValue('messenger-channel'), - }), - getSender: jest.fn().mockReturnValue({ - id: subscriber.id, - firstName: subscriber.firstName, - lastName: subscriber.lastName, - language: subscriber.language, - context: { - vars: { - email: 'john.doe@mail.com', - }, - }, - }), - setSender: jest.fn(), - } as unknown as EventWrapper; - const result2 = await conversationService.storeContextData( - conversation, - next, - event2, - ); - - expect(result2.context.skip['block-1']).toBe(10); - }); - }); -}); diff --git a/packages/api/src/chat/services/conversation.service.ts b/packages/api/src/chat/services/conversation.service.ts deleted file mode 100644 index 6e2061d97..000000000 --- a/packages/api/src/chat/services/conversation.service.ts +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; - -import EventWrapper from '@/channel/lib/EventWrapper'; -import { BaseOrmService } from '@/utils/generics/base-orm.service'; - -import { getDefaultConversationContext } from '../constants/conversation'; -import { Block, BlockFull } from '../dto/block.dto'; -import { - Conversation, - ConversationDtoConfig, - ConversationFull, - ConversationTransformerDto, -} from '../dto/conversation.dto'; -import { ConversationOrmEntity } from '../entities/conversation.entity'; -import { VIEW_MORE_PAYLOAD } from '../helpers/constants'; -import { ConversationRepository } from '../repositories/conversation.repository'; -import { OutgoingMessageFormat } from '../types/message'; -import { Payload } from '../types/quick-reply'; - -import { ContextVarService } from './context-var.service'; -import { SubscriberService } from './subscriber.service'; - -@Injectable() -export class ConversationService extends BaseOrmService< - ConversationOrmEntity, - ConversationTransformerDto, - ConversationDtoConfig, - ConversationRepository -> { - constructor( - readonly repository: ConversationRepository, - private readonly contextVarService: ContextVarService, - private readonly subscriberService: SubscriberService, - ) { - super(repository); - } - - /** - * Marks the conversation as inactive. - * - * @param convo - The conversation - */ - async end(convo: Conversation | ConversationFull) { - return await this.repository.end(convo); - } - - /** - * Saves the actual conversation context and returns the updated conversation - * - * @param convo - The Current Conversation - * @param next - The next block to be triggered - * @param event - The event received - * @param shouldCaptureVars - If we should capture vars or not - * - * @returns The updated conversation - */ - async storeContextData( - convo: Conversation | ConversationFull, - next: Block | BlockFull, - event: EventWrapper, - shouldCaptureVars: boolean = false, - ) { - const defaultContext = getDefaultConversationContext(); - if (!convo.context) { - convo.context = defaultContext; - } - - const msgType = event.getMessageType(); - const profile = event.getSender(); - - convo.context.vars = convo.context.vars ?? {}; - convo.context.skip = convo.context.skip ?? {}; - convo.context.user = convo.context.user ?? defaultContext.user; - convo.context.user_location = - convo.context.user_location ?? defaultContext.user_location; - convo.context.attempt = convo.context.attempt ?? defaultContext.attempt; - - // Capture channel specific context data - convo.context.channel = event.getHandler().getName(); - convo.context.text = event.getText() ?? null; - convo.context.payload = event.getPayload() ?? null; - convo.context.nlp = event.getNLP() ?? null; - - const contextVars = - await this.contextVarService.getContextVarsByBlock(next); - - // Capture user entry in context vars - if ( - shouldCaptureVars && - next.capture_vars && - next.capture_vars.length > 0 - ) { - next.capture_vars.forEach((capture) => { - let contextValue: string | Payload | undefined; - - const nlp = event.getNLP(); - - if (nlp && nlp.entities && nlp.entities.length) { - const nlpIndex = nlp.entities - .map((n) => { - return n.entity; - }) - .indexOf(capture.entity.toString()); - if (capture.entity && nlpIndex !== -1) { - // Get the most confident value - contextValue = nlp.entities[nlpIndex].value; - } - } - - if (capture.entity === -1) { - // Capture the whole message - contextValue = - msgType && ['message', 'quick_reply'].indexOf(msgType) !== -1 - ? event.getText() - : event.getPayload(); - } else if (capture.entity === -2) { - // Capture the postback payload (button click) - contextValue = event.getPayload(); - } - contextValue = - typeof contextValue === 'string' ? contextValue.trim() : contextValue; - - if (contextValue) { - convo.context.vars[capture.context_var] = contextValue; - } - }); - } - - // Store user infos - if (profile) { - convo.context.user.id = profile.id; - convo.context.user.firstName = profile.firstName || ''; - convo.context.user.lastName = profile.lastName || ''; - if (profile.language) { - convo.context.user.language = profile.language; - } - } - - // Handle attachments (location, ...) - const msg = event.getMessage(); - if (msgType === 'location' && 'coordinates' in msg) { - const coordinates = msg.coordinates; - convo.context.user_location = { - lat: parseFloat(coordinates.lat.toString()), - lon: parseFloat(coordinates.lon.toString()), - }; - } - - // Deal with load more in the case of a list display - if ( - next.options && - next.options.content && - (next.options.content.display === OutgoingMessageFormat.list || - next.options.content.display === OutgoingMessageFormat.carousel) - ) { - if (event.getPayload() === VIEW_MORE_PAYLOAD) { - const currentSkip = convo.context.skip[next.id] ?? 0; - convo.context.skip[next.id] = - currentSkip + (next.options.content.limit ?? 0); - } else { - convo.context.skip[next.id] = 0; - } - } - - // Store new context data - try { - const updatedConversation = await this.updateOne(convo.id, { - context: convo.context, - }); - //TODO: add check if nothing changed don't update - const criteria = - typeof convo.sender === 'object' ? convo.sender.id : convo.sender; - // Store permanent context vars at the subscriber level - const permanentContextVars = Object.entries(contextVars) - .filter(([, { permanent }]) => permanent) - .reduce((acc, [cur]) => { - if (cur in convo.context.vars) { - acc[cur] = convo.context.vars[cur]; - } - - return acc; - }, {}); - - if (Object.keys(permanentContextVars).length) { - const updatedSubscriber = await this.subscriberService.updateOne( - criteria, - { - context: { vars: permanentContextVars }, - }, - { - shouldFlatten: true, - }, - ); - - event.setSender(updatedSubscriber); - } - - return updatedConversation; - } catch (err) { - this.logger.error('Conversation Model : Unable to store context', err); - throw err; - } - } -} diff --git a/packages/api/src/chat/services/subscriber.service.ts b/packages/api/src/chat/services/subscriber.service.ts index bf05f075a..e0b6ec35a 100644 --- a/packages/api/src/chat/services/subscriber.service.ts +++ b/packages/api/src/chat/services/subscriber.service.ts @@ -292,14 +292,14 @@ export class SubscriberService extends BaseOrmService< current = await this.handOver(current, assignTo); } - this.logger.debug('Block updates have been applied!', { + this.logger.debug('Subscriber updates have been applied!', { labels, assignTo, }); return current; } catch (err) { - this.logger.error('Unable to perform block updates!', err); + this.logger.error('Unable to perform subscriber updates!', err); throw err; } } diff --git a/packages/api/src/chat/types/attachment.ts b/packages/api/src/chat/types/attachment.ts index cbd071a9e..5dcf9dd82 100644 --- a/packages/api/src/chat/types/attachment.ts +++ b/packages/api/src/chat/types/attachment.ts @@ -21,7 +21,7 @@ export const fileTypeSchema = z.nativeEnum(FileType); * 1. By `id`: This is used when the attachment is uploaded and stored in the Hexabot system. * The `id` field represents the unique identifier of the uploaded attachment in the system. * 2. By `url`: This is used when the attachment is externally hosted, especially when - * the content is generated or retrieved by a plugin that consumes a third-party API. + * the content is generated or retrieved by an action that consumes a third-party API. * In this case, the `url` field contains the direct link to the external resource. */ diff --git a/packages/api/src/chat/types/message.ts b/packages/api/src/chat/types/message.ts index 54ed34c3e..f81992bed 100644 --- a/packages/api/src/chat/types/message.ts +++ b/packages/api/src/chat/types/message.ts @@ -6,14 +6,12 @@ import { z } from 'zod'; -import { PluginName } from '@/plugins/types'; - import { Message } from '../dto/message.dto'; -import { attachmentPayloadSchema, FileType } from './attachment'; +import { attachmentPayloadSchema } from './attachment'; import { buttonSchema, PayloadType } from './button'; import { contentOptionsSchema } from './options'; -import { QuickReplyType, stdQuickReplySchema } from './quick-reply'; +import { stdQuickReplySchema } from './quick-reply'; /** * StdEventType enum is declared, and currently not used @@ -137,29 +135,6 @@ export type StdOutgoingSystemMessage = z.infer< typeof stdOutgoingSystemMessageSchema >; -export const pluginNameSchema = z - .string() - .regex(/-plugin$/) as z.ZodType; - -export const stdPluginMessageSchema = z.object({ - plugin: pluginNameSchema, - args: z.record(z.any()), -}); - -export type StdPluginMessage = z.infer; - -export const blockMessageSchema = z.union([ - z.array(z.string()), - stdOutgoingTextMessageSchema, - stdOutgoingQuickRepliesMessageSchema, - stdOutgoingButtonsMessageSchema, - stdOutgoingListMessageSchema, - stdOutgoingAttachmentMessageSchema, - stdPluginMessageSchema, -]); - -export type BlockMessage = z.infer; - export const StdOutgoingMessageSchema = z.union([ stdOutgoingTextMessageSchema, stdOutgoingQuickRepliesMessageSchema, @@ -312,82 +287,3 @@ export type StdOutgoingEnvelope = z.infer; export const validMessageTextSchema = z.object({ text: z.string(), }); - -export const textSchema = z.array(z.string().max(1000)); - -const quickReplySchema = z - .object({ - content_type: z.nativeEnum(QuickReplyType), - title: z.string().max(20).optional(), - payload: z.string().max(1000).optional(), - }) - .superRefine((data, ctx) => { - // When content_type is 'text', title and payload are required. - if (data.content_type === QuickReplyType.text) { - if (data.title == null) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Title is required when content_type is 'text'", - path: ['title'], - }); - } - if (data.payload == null) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Payload is required when content_type is 'text'", - path: ['payload'], - }); - } - } - }); - -// pluginBlockMessageSchema in case of Plugin Block -export const pluginBlockMessageSchema = z - .record(z.any()) - .superRefine((data, ctx) => { - if (!('plugin' in data)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "The object must contain the 'plugin' attribute", - path: ['plugin'], - }); - } - }); - -// textBlockMessageSchema in case of Text Block -const textBlockMessageSchema = z.string().max(1000); -const buttonMessageSchema = z.object({ - text: z.string(), - buttons: z.array(buttonSchema).max(3), -}); -// quickReplyMessageSchema in case of QuickReply Block -const quickReplyMessageSchema = z.object({ - text: z.string(), - quickReplies: z.array(quickReplySchema).max(11).optional(), -}); -// listBlockMessageSchema in case of List Block -const listBlockMessageSchema = z.object({ - elements: z.boolean(), -}); -// attachmentBlockMessageSchema in case of Attachment Block -const attachmentBlockMessageSchema = z.object({ - text: z.string().max(1000).optional(), - attachment: z.object({ - type: z.nativeEnum(FileType), - payload: z.union([ - z.object({ url: z.string().url() }), - z.object({ id: z.string().nullable() }), - ]), - }), -}); - -// BlockMessage Schema -export const blockMessageObjectSchema = z.union([ - textSchema, - textBlockMessageSchema, - buttonMessageSchema, - quickReplyMessageSchema, - listBlockMessageSchema, - attachmentBlockMessageSchema, - pluginBlockMessageSchema, -]); diff --git a/packages/api/src/chat/types/options.ts b/packages/api/src/chat/types/options.ts index a67a1c15c..9c385d500 100644 --- a/packages/api/src/chat/types/options.ts +++ b/packages/api/src/chat/types/options.ts @@ -35,7 +35,7 @@ export const fallbackOptionsSchema = z.object({ export type FallbackOptions = z.infer; -export const BlockOptionsSchema = z.object({ +export const ActionOptionsSchema = z.object({ typing: z.number().optional(), content: contentOptionsSchema.optional(), fallback: fallbackOptionsSchema.optional(), @@ -43,4 +43,4 @@ export const BlockOptionsSchema = z.object({ effects: z.array(z.string()).optional(), }); -export type BlockOptions = z.infer; +export type ActionOptions = z.infer; diff --git a/packages/api/src/cms/controllers/content-type.controller.spec.ts b/packages/api/src/cms/controllers/content-type.controller.spec.ts index d42179522..ec8afa5af 100644 --- a/packages/api/src/cms/controllers/content-type.controller.spec.ts +++ b/packages/api/src/cms/controllers/content-type.controller.spec.ts @@ -7,7 +7,6 @@ import { NotFoundException } from '@nestjs/common'; import { TestingModule } from '@nestjs/testing'; -import { BlockService } from '@/chat/services/block.service'; import { LoggerService } from '@/logger/logger.service'; import { FieldType } from '@/setting/types'; import { @@ -27,20 +26,12 @@ describe('ContentTypeController (TypeORM)', () => { let service: ContentTypeService; let logger: LoggerService; const createdIds = new Set(); - const blockServiceMock = { - findOne: jest.fn().mockResolvedValue(null), - }; beforeAll(async () => { const { module: testingModule, getMocks } = await buildTestingMocks({ autoInjectFrom: ['controllers'], controllers: [ContentTypeController], - providers: [ - { - provide: BlockService, - useFactory: () => blockServiceMock, - }, - ], + providers: [], typeorm: { fixtures: installContentTypeFixturesTypeOrm, }, diff --git a/packages/api/src/cms/controllers/content.controller.spec.ts b/packages/api/src/cms/controllers/content.controller.spec.ts index c1c2394e2..1b78ae031 100644 --- a/packages/api/src/cms/controllers/content.controller.spec.ts +++ b/packages/api/src/cms/controllers/content.controller.spec.ts @@ -9,7 +9,6 @@ import { randomUUID } from 'crypto'; import { NotFoundException } from '@nestjs/common'; import { TestingModule } from '@nestjs/testing'; -import { BlockService } from '@/chat/services/block.service'; import { LoggerService } from '@/logger/logger.service'; import { contentFixtures, @@ -31,20 +30,12 @@ describe('ContentController (TypeORM)', () => { let contentTypeService: ContentTypeService; let logger: LoggerService; const createdContentIds = new Set(); - const blockServiceMock = { - findOne: jest.fn().mockResolvedValue(null), - }; beforeAll(async () => { const { module: testingModule, getMocks } = await buildTestingMocks({ autoInjectFrom: ['controllers'], controllers: [ContentController], - providers: [ - { - provide: BlockService, - useFactory: () => blockServiceMock, - }, - ], + providers: [], typeorm: [ { fixtures: [ diff --git a/packages/api/src/cms/entities/content-type.entity.ts b/packages/api/src/cms/entities/content-type.entity.ts index efb01bd38..80204c6d6 100644 --- a/packages/api/src/cms/entities/content-type.entity.ts +++ b/packages/api/src/cms/entities/content-type.entity.ts @@ -4,8 +4,7 @@ * Full terms: see LICENSE.md. */ -import { ForbiddenException } from '@nestjs/common'; -import { BeforeRemove, Column, Entity, Index, OneToMany } from 'typeorm'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; import { JsonColumn } from '@/database/decorators/json-column.decorator'; import { BaseOrmEntity } from '@/database/entities/base.entity'; @@ -27,40 +26,4 @@ export class ContentTypeOrmEntity extends BaseOrmEntity { cascade: ['remove'], }) contents?: ContentOrmEntity[]; - - @BeforeRemove() - protected async ensureNoAssociatedBlocks(): Promise { - if (!this.id) { - return; - } - - const manager = ContentTypeOrmEntity.getEntityManager(); - const databaseType = manager.connection.options.type; - const blockQuery = manager - .createQueryBuilder() - .select('1') - .from('blocks', 'block'); - - if (databaseType === 'sqlite' || databaseType === 'better-sqlite3') { - blockQuery.where( - `json_extract(block.options, '$.content.entity') = :contentTypeId`, - { contentTypeId: this.id }, - ); - } else if (databaseType === 'postgres') { - blockQuery.where( - `(block.options -> 'content' ->> 'entity') = :contentTypeId`, - { contentTypeId: this.id }, - ); - } else { - throw new Error( - `Unsupported database type for content type deletion safeguard: ${databaseType}`, - ); - } - - const associatedBlock = await blockQuery.limit(1).getRawOne(); - - if (associatedBlock) { - throw new ForbiddenException('Content type have blocks associated to it'); - } - } } diff --git a/packages/api/src/cms/repositories/content-type.repository.spec.ts b/packages/api/src/cms/repositories/content-type.repository.spec.ts index 97e5f385e..0030f103f 100644 --- a/packages/api/src/cms/repositories/content-type.repository.spec.ts +++ b/packages/api/src/cms/repositories/content-type.repository.spec.ts @@ -6,9 +6,7 @@ import { randomUUID } from 'crypto'; -import { ForbiddenException } from '@nestjs/common'; import { TestingModule } from '@nestjs/testing'; -import { DataSource } from 'typeorm'; import { FieldType } from '@/setting/types'; import { closeTypeOrmConnections } from '@/utils/test/test'; @@ -21,7 +19,6 @@ describe('ContentTypeRepository (TypeORM)', () => { let module: TestingModule; let repository: ContentTypeRepository; let contentRepository: ContentRepository; - let dataSource: DataSource; const buildRequiredFields = () => [ { @@ -46,15 +43,6 @@ describe('ContentTypeRepository (TypeORM)', () => { ContentTypeRepository, ContentRepository, ]); - dataSource = module.get(DataSource); - await dataSource.query(` - CREATE TABLE IF NOT EXISTS blocks ( - id varchar PRIMARY KEY, - name varchar NOT NULL, - options text NOT NULL, - message text NOT NULL - ) - `); }); afterAll(async () => { @@ -64,65 +52,6 @@ describe('ContentTypeRepository (TypeORM)', () => { await closeTypeOrmConnections(); }); - describe('preDelete block association guard', () => { - it('rejects deletion when a block is associated to the content type', async () => { - const created = await repository.create({ - name: `type-${randomUUID()}`, - fields: buildRequiredFields(), - }); - const blockId = `block-${randomUUID()}`; - const blockName = `block-name-${randomUUID()}`; - const options = { - content: { - display: 'list', - fields: { - title: 'Title', - subtitle: null, - image_url: null, - }, - buttons: [], - limit: 5, - entity: created.id, - }, - }; - await dataSource.query( - `INSERT INTO blocks (id, name, options, message) VALUES (?, ?, ?, ?)`, - [ - blockId, - blockName, - JSON.stringify(options), - JSON.stringify(['Hello']), - ], - ); - - const escapeLikePattern = (value: string) => - value.replace(/[%_]/g, '\\$&'); - const pattern = `%"content":%"entity":"${escapeLikePattern( - created.id, - )}"%`; - const match = await dataSource - .createQueryBuilder() - .select('1') - .from('blocks', 'block') - .where('block.options LIKE :pattern', { pattern }) - .limit(1) - .getRawOne(); - expect(match).toBeDefined(); - - await expect(repository.deleteOne(created.id)).rejects.toThrow( - ForbiddenException, - ); - - const contentType = await repository.findOne({ - where: { id: created.id }, - }); - expect(contentType).not.toBeNull(); - - await dataSource.query(`DELETE FROM blocks WHERE id = ?`, [blockId]); - await repository.deleteOne(created.id); - }); - }); - describe('deleteOne cascade', () => { it('removes related contents when deleting a content type', async () => { const created = await repository.create({ diff --git a/packages/api/src/cms/services/content-type.service.spec.ts b/packages/api/src/cms/services/content-type.service.spec.ts index dfec166f4..cc3c9ae23 100644 --- a/packages/api/src/cms/services/content-type.service.spec.ts +++ b/packages/api/src/cms/services/content-type.service.spec.ts @@ -6,7 +6,6 @@ import { TestingModule } from '@nestjs/testing'; -import { BlockService } from '@/chat/services/block.service'; import { FieldType } from '@/setting/types'; import { contentTypeOrmFixtures, @@ -23,20 +22,11 @@ describe('ContentTypeService (TypeORM)', () => { let module: TestingModule; let service: ContentTypeService; const createdIds: string[] = []; - const blockServiceMock = { - findOne: jest.fn().mockResolvedValue(null), - }; beforeAll(async () => { const { module: testingModule, getMocks } = await buildTestingMocks({ autoInjectFrom: ['providers'], - providers: [ - ContentTypeService, - { - provide: BlockService, - useFactory: () => blockServiceMock, - }, - ], + providers: [ContentTypeService], typeorm: { fixtures: installContentTypeFixturesTypeOrm, }, diff --git a/packages/api/src/cms/services/content.service.spec.ts b/packages/api/src/cms/services/content.service.spec.ts index 4f2334816..67cc640e1 100644 --- a/packages/api/src/cms/services/content.service.spec.ts +++ b/packages/api/src/cms/services/content.service.spec.ts @@ -6,7 +6,6 @@ import { TestingModule } from '@nestjs/testing'; -import { BlockService } from '@/chat/services/block.service'; import { OutgoingMessageFormat } from '@/chat/types/message'; import { ContentOptions } from '@/chat/types/options'; import { LoggerService } from '@/logger/logger.service'; @@ -31,16 +30,7 @@ describe('ContentService (TypeORM)', () => { beforeAll(async () => { const { module: testingModule, getMocks } = await buildTestingMocks({ autoInjectFrom: ['providers'], - providers: [ - ContentService, - ContentTypeService, - { - provide: BlockService, - useValue: { - findOne: jest.fn(() => null), - }, - }, - ], + providers: [ContentService, ContentTypeService], typeorm: [ { fixtures: [ diff --git a/packages/api/src/extension/cleanup.service.spec.ts b/packages/api/src/extension/cleanup.service.spec.ts index 218af447f..6f5ecbf82 100644 --- a/packages/api/src/extension/cleanup.service.spec.ts +++ b/packages/api/src/extension/cleanup.service.spec.ts @@ -16,7 +16,6 @@ import { closeTypeOrmConnections } from '@/utils/test/test'; import { buildTestingMocks } from '@/utils/test/utils'; import { CleanupService } from './cleanup.service'; -import { TNamespace } from './types'; const channelServiceMock = { getAll: jest.fn().mockReturnValue([]), @@ -98,7 +97,9 @@ describe('CleanupService', () => { const filteredSettings = initialSettings.filter( ({ group }) => !/_(channel|helper)$/.test(group) !== - registeredNamespaces.includes(group as TNamespace), + registeredNamespaces.includes( + group as `${string}_channel` | `${string}_helper`, + ), ); expect(sortSettings(cleanSettings)).toEqualPayload( diff --git a/packages/api/src/extension/types.ts b/packages/api/src/extension/types.ts index 283914181..bfbac0754 100644 --- a/packages/api/src/extension/types.ts +++ b/packages/api/src/extension/types.ts @@ -4,22 +4,13 @@ * Full terms: see LICENSE.md. */ -import { ExtensionName } from '@/utils/types/extension'; +import { ExtensionName, HyphenToUnderscore } from '@/utils/types/extension'; -type TExcludedExtension = 'plugin'; +export type TExtensionName = Extract; -type TExcludeSuffix< - T, - S extends string = '_', - Suffix extends string = `${S}${TExcludedExtension}`, -> = T extends `${infer _Base}${Suffix}` ? never : T; - -export type TExtensionName = TExcludeSuffix; - -export type TExtension = - Extract extends `${string}-${infer S}` - ? `${S}` - : never; +export type TExtension = TExtensionName extends `${string}-${infer S}` + ? `${S}` + : never; export type TNamespace = HyphenToUnderscore; diff --git a/packages/api/src/extensions/actions/messaging/README.md b/packages/api/src/extensions/actions/messaging/README.md new file mode 100644 index 000000000..fab88f8be --- /dev/null +++ b/packages/api/src/extensions/actions/messaging/README.md @@ -0,0 +1,7 @@ +Built-in Hexabot actions live here. Add new `.action.ts` files to expose them through the dynamic action registry. + +Available actions: +- `send_text_message` — sends a text envelope and suspends the workflow until the subscriber replies. +- `send_quick_replies` — sends text with quick replies and waits for the chosen reply. +- `send_buttons` — sends text with buttons and waits for the follow-up message. +- `send_attachment` — sends an attachment (with optional quick replies) and waits for the reply. diff --git a/packages/api/src/extensions/actions/messaging/attachment.action.ts b/packages/api/src/extensions/actions/messaging/attachment.action.ts new file mode 100644 index 000000000..7f049f3d3 --- /dev/null +++ b/packages/api/src/extensions/actions/messaging/attachment.action.ts @@ -0,0 +1,61 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { ActionExecutionArgs } from '@hexabot-ai/agentic'; +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; + +import { ActionService } from '@/actions/actions.service'; +import { attachmentPayloadSchema } from '@/chat/types/attachment'; +import { stdIncomingMessageSchema } from '@/chat/types/message'; +import { stdQuickReplySchema } from '@/chat/types/quick-reply'; +import { WorkflowContext } from '@/workflow/services/workflow-context'; + +import { MessageAction } from '../messaging/message-action.base'; + +const attachmentInputSchema = z.object({ + attachment: attachmentPayloadSchema, + quick_replies: z.array(stdQuickReplySchema).optional(), + options: z.record(z.any()).optional(), +}); + +type AttachmentInput = z.infer; + +@Injectable() +export class SendAttachmentAction extends MessageAction { + constructor(actionService: ActionService) { + super( + { + name: 'send_attachment', + description: + 'Sends an attachment message to the subscriber and waits for the reply.', + inputSchema: attachmentInputSchema, + outputSchema: stdIncomingMessageSchema, + }, + actionService, + ); + } + + async execute({ + input, + context, + }: ActionExecutionArgs) { + const prepared = await this.prepare(context); + const envelope = prepared.envelopeFactory.buildAttachmentEnvelope( + input.attachment, + input.quick_replies ?? [], + ); + + return this.sendPreparedAndSuspend( + context, + prepared, + envelope, + input.options, + ); + } +} + +export default SendAttachmentAction; diff --git a/packages/api/src/extensions/actions/messaging/buttons.action.ts b/packages/api/src/extensions/actions/messaging/buttons.action.ts new file mode 100644 index 000000000..25dfa35ad --- /dev/null +++ b/packages/api/src/extensions/actions/messaging/buttons.action.ts @@ -0,0 +1,60 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { ActionExecutionArgs } from '@hexabot-ai/agentic'; +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; + +import { ActionService } from '@/actions/actions.service'; +import { buttonSchema } from '@/chat/types/button'; +import { stdIncomingMessageSchema } from '@/chat/types/message'; +import { WorkflowContext } from '@/workflow/services/workflow-context'; + +import { MessageAction } from './message-action.base'; + +const buttonsInputSchema = z.object({ + text: z.union([z.string(), z.array(z.string())]), + buttons: z.array(buttonSchema).min(1, 'Provide at least one button'), + options: z.record(z.any()).optional(), +}); + +type ButtonsInput = z.infer; + +@Injectable() +export class SendButtonsAction extends MessageAction { + constructor(actionService: ActionService) { + super( + { + name: 'send_buttons', + description: + 'Sends a text message with buttons to the subscriber and waits for the reply.', + inputSchema: buttonsInputSchema, + outputSchema: stdIncomingMessageSchema, + }, + actionService, + ); + } + + async execute({ + input, + context, + }: ActionExecutionArgs) { + const prepared = await this.prepare(context); + const envelope = prepared.envelopeFactory.buildButtonsEnvelope( + input.text, + input.buttons, + ); + + return this.sendPreparedAndSuspend( + context, + prepared, + envelope, + input.options, + ); + } +} + +export default SendButtonsAction; diff --git a/packages/api/src/extensions/actions/messaging/message-action.base.ts b/packages/api/src/extensions/actions/messaging/message-action.base.ts new file mode 100644 index 000000000..b7f1de34a --- /dev/null +++ b/packages/api/src/extensions/actions/messaging/message-action.base.ts @@ -0,0 +1,170 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { ActionMetadata } from '@hexabot-ai/agentic'; + +import { ActionService } from '@/actions/actions.service'; +import { BaseAction } from '@/actions/base-action'; +import { BotStatsType } from '@/analytics/entities/bot-stats.entity'; +import EventWrapper from '@/channel/lib/EventWrapper'; +import { MessageCreateDto } from '@/chat/dto/message.dto'; +import { Subscriber } from '@/chat/dto/subscriber.dto'; +import { EnvelopeFactory } from '@/chat/helpers/envelope-factory'; +import { Context } from '@/chat/types/context'; +import { + StdIncomingMessage, + StdOutgoingMessageEnvelope, +} from '@/chat/types/message'; +import { getDefaultWorkflowContext } from '@/workflow/defaults/context'; +import { WorkflowContext } from '@/workflow/services/workflow-context'; + +export type MessageActionOutput = StdIncomingMessage; + +interface PreparedMessageContext { + event: EventWrapper; + recipient: Subscriber; + envelopeFactory: EnvelopeFactory; + chatContext: Context; +} + +export abstract class MessageAction extends BaseAction< + I, + MessageActionOutput, + WorkflowContext +> { + protected constructor( + metadata: ActionMetadata, + actionService: ActionService, + ) { + super(metadata, actionService); + } + + private ensureEvent(context: WorkflowContext) { + if (!context.event) { + throw new Error('Missing event on workflow context'); + } + + return context.event; + } + + private buildChatContext( + event: EventWrapper, + chatContext?: Context, + ) { + const defaults = getDefaultWorkflowContext(); + const base = chatContext ?? defaults; + const sender = event.getSender(); + const mergedUser = { + ...defaults.user, + ...(base.user ?? {}), + ...(sender ?? {}), + } as Subscriber; + const mergedVars = { + ...defaults.vars, + ...(base.vars ?? {}), + ...(sender?.context?.vars ?? {}), + }; + + return { + ...defaults, + ...base, + vars: mergedVars, + skip: { ...defaults.skip, ...(base.skip ?? {}) }, + user_location: { + ...defaults.user_location, + ...(base.user_location ?? {}), + }, + attempt: base.attempt ?? defaults.attempt, + user: mergedUser, + channel: base.channel ?? event.getHandler().getName(), + } satisfies Context; + } + + protected async prepare( + context: WorkflowContext, + ): Promise { + const event = this.ensureEvent(context); + const recipient = event.getSender(); + + if (!recipient) { + throw new Error('Missing recipient on event'); + } + + const chatContext = this.buildChatContext(event, context.chatContext); + const { settings: settingService, i18n } = context.services; + const settings = await settingService.getSettings(); + const envelopeFactory = new EnvelopeFactory(chatContext, settings, i18n); + + return { + event, + recipient, + envelopeFactory, + chatContext, + }; + } + + protected async sendPreparedAndSuspend( + workflowContext: WorkflowContext, + prepared: PreparedMessageContext, + envelope: StdOutgoingMessageEnvelope, + options?: any, + ): Promise { + const { event, recipient, chatContext } = prepared; + const eventEmitter = workflowContext.eventEmitter!; + const { logger } = workflowContext.services; + + logger.debug('Sending action message ... ', event.getSenderForeignId()); + const response = await event + .getHandler() + .sendMessage(event, envelope, options, chatContext); + + eventEmitter.emit('hook:stats:entry', BotStatsType.outgoing, 'Outgoing'); + eventEmitter.emit( + 'hook:stats:entry', + BotStatsType.all_messages, + 'All Messages', + ); + + const mid = response && 'mid' in response ? response.mid : ''; + const sentMessage: MessageCreateDto = { + mid, + message: envelope.message, + recipient: recipient.id, + handover: false, + read: false, + delivery: false, + }; + await eventEmitter.emitAsync('hook:chatbot:sent', sentMessage, event); + + return workflowContext.workflow.suspend({ + reason: 'awaiting_user_response', + data: { + action: this.getName(), + channel: event.getHandler().getName(), + recipient: recipient.id, + workflowRunId: workflowContext.workflowRunId, + messageId: mid || undefined, + format: envelope.format, + envelope: envelope.message, + }, + }); + } + + protected async sendAndSuspend( + workflowContext: WorkflowContext, + envelope: StdOutgoingMessageEnvelope, + options?: any, + ): Promise { + const prepared = await this.prepare(workflowContext); + + return this.sendPreparedAndSuspend( + workflowContext, + prepared, + envelope, + options, + ); + } +} diff --git a/packages/api/src/extensions/actions/messaging/quick-replies.action.ts b/packages/api/src/extensions/actions/messaging/quick-replies.action.ts new file mode 100644 index 000000000..f35eb755f --- /dev/null +++ b/packages/api/src/extensions/actions/messaging/quick-replies.action.ts @@ -0,0 +1,62 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { ActionExecutionArgs } from '@hexabot-ai/agentic'; +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; + +import { ActionService } from '@/actions/actions.service'; +import { stdIncomingMessageSchema } from '@/chat/types/message'; +import { stdQuickReplySchema } from '@/chat/types/quick-reply'; +import { WorkflowContext } from '@/workflow/services/workflow-context'; + +import { MessageAction } from './message-action.base'; + +const quickRepliesInputSchema = z.object({ + text: z.union([z.string(), z.array(z.string())]), + quick_replies: z + .array(stdQuickReplySchema) + .min(1, 'Provide at least one quick reply'), + options: z.record(z.any()).optional(), +}); + +type QuickRepliesInput = z.infer; + +@Injectable() +export class SendQuickRepliesAction extends MessageAction { + constructor(actionService: ActionService) { + super( + { + name: 'send_quick_replies', + description: + 'Sends a text message with quick replies to the subscriber and waits for the reply.', + inputSchema: quickRepliesInputSchema, + outputSchema: stdIncomingMessageSchema, + }, + actionService, + ); + } + + async execute({ + input, + context, + }: ActionExecutionArgs) { + const prepared = await this.prepare(context); + const envelope = prepared.envelopeFactory.buildQuickRepliesEnvelope( + input.text, + input.quick_replies, + ); + + return this.sendPreparedAndSuspend( + context, + prepared, + envelope, + input.options, + ); + } +} + +export default SendQuickRepliesAction; diff --git a/packages/api/src/extensions/actions/messaging/text-message.action.ts b/packages/api/src/extensions/actions/messaging/text-message.action.ts new file mode 100644 index 000000000..ed8a27444 --- /dev/null +++ b/packages/api/src/extensions/actions/messaging/text-message.action.ts @@ -0,0 +1,55 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { ActionExecutionArgs } from '@hexabot-ai/agentic'; +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; + +import { ActionService } from '@/actions/actions.service'; +import { stdIncomingMessageSchema } from '@/chat/types/message'; +import { WorkflowContext } from '@/workflow/services/workflow-context'; + +import { MessageAction } from './message-action.base'; + +const textMessageInputSchema = z.object({ + text: z.union([z.string(), z.array(z.string())]), + options: z.record(z.any()).optional(), +}); + +type TextMessageInput = z.infer; + +@Injectable() +export class SendTextMessageAction extends MessageAction { + constructor(actionService: ActionService) { + super( + { + name: 'send_text_message', + description: + 'Sends a text message to the subscriber and waits for the reply.', + inputSchema: textMessageInputSchema, + outputSchema: stdIncomingMessageSchema, + }, + actionService, + ); + } + + async execute({ + input, + context, + }: ActionExecutionArgs) { + const prepared = await this.prepare(context); + const envelope = prepared.envelopeFactory.buildTextEnvelope(input.text); + + return this.sendPreparedAndSuspend( + context, + prepared, + envelope, + input.options, + ); + } +} + +export default SendTextMessageAction; diff --git a/packages/api/src/extensions/channels/console/settings.ts b/packages/api/src/extensions/channels/console/settings.ts index 313ae32f6..e49a107eb 100644 --- a/packages/api/src/extensions/channels/console/settings.ts +++ b/packages/api/src/extensions/channels/console/settings.ts @@ -40,7 +40,7 @@ export default [ { group: CONSOLE_CHANNEL_NAMESPACE, label: 'greeting_message', - value: 'Welcome! Ready to start a conversation with our chatbot?', + value: 'Welcome! Ready to start chatting with our chatbot?', type: SettingType.textarea, }, { diff --git a/packages/api/src/extensions/channels/web/base-web-channel.ts b/packages/api/src/extensions/channels/web/base-web-channel.ts index 17bae09c7..fa9c363b7 100644 --- a/packages/api/src/extensions/channels/web/base-web-channel.ts +++ b/packages/api/src/extensions/channels/web/base-web-channel.ts @@ -50,7 +50,7 @@ import { StdOutgoingQuickRepliesMessage, StdOutgoingTextMessage, } from '@/chat/types/message'; -import { BlockOptions } from '@/chat/types/options'; +import { ActionOptions } from '@/chat/types/options'; import { ContentOrmEntity } from '@/cms/entities/content.entity'; import { MenuService } from '@/cms/services/menu.service'; import { config } from '@/config'; @@ -960,7 +960,7 @@ export default abstract class BaseWebChannelHandler< */ _textFormat( message: StdOutgoingTextMessage, - _options?: BlockOptions, + _options?: ActionOptions, ): Web.OutgoingMessageBase { return { type: Web.OutgoingMessageType.text, @@ -978,7 +978,7 @@ export default abstract class BaseWebChannelHandler< */ _quickRepliesFormat( message: StdOutgoingQuickRepliesMessage, - _options?: BlockOptions, + _options?: ActionOptions, ): Web.OutgoingMessageBase { return { type: Web.OutgoingMessageType.quick_replies, @@ -999,7 +999,7 @@ export default abstract class BaseWebChannelHandler< */ _buttonsFormat( message: StdOutgoingButtonsMessage, - _options?: BlockOptions, + _options?: ActionOptions, ): Web.OutgoingMessageBase { return { type: Web.OutgoingMessageType.buttons, @@ -1020,7 +1020,7 @@ export default abstract class BaseWebChannelHandler< */ async _attachmentFormat( message: StdOutgoingAttachmentMessage, - _options?: BlockOptions, + _options?: ActionOptions, ): Promise { const payload: Web.OutgoingMessageBase = { type: Web.OutgoingMessageType.file, @@ -1052,7 +1052,7 @@ export default abstract class BaseWebChannelHandler< */ async _formatElements( data: ContentElement[], - options: BlockOptions, + options: ActionOptions, ): Promise { if (!options.content || !options.content.fields) { throw new Error('Content options are missing the fields'); @@ -1136,7 +1136,7 @@ export default abstract class BaseWebChannelHandler< */ async _listFormat( message: StdOutgoingListMessage, - options: BlockOptions, + options: ActionOptions, ): Promise { const data = message.elements || []; const pagination = message.pagination; @@ -1188,7 +1188,7 @@ export default abstract class BaseWebChannelHandler< */ async _carouselFormat( message: StdOutgoingListMessage, - options: BlockOptions, + options: ActionOptions, ): Promise { const data = message.elements || []; // Items count min check @@ -1214,13 +1214,13 @@ export default abstract class BaseWebChannelHandler< * Creates an widget compliant data structure for any message envelope * * @param envelope - The message standard envelope - * @param options - The block options related to the message + * @param options - The action options related to the message * * @returns A template filled with its payload */ async _formatMessage( envelope: StdOutgoingEnvelope, - options: BlockOptions, + options: ActionOptions, ): Promise { switch (envelope.format) { case OutgoingMessageFormat.attachment: @@ -1276,7 +1276,7 @@ export default abstract class BaseWebChannelHandler< async sendMessage( event: WebEventWrapper, envelope: StdOutgoingEnvelope, - options: BlockOptions, + options: ActionOptions, _context?: any, ): Promise<{ mid: string }> { const messageBase: Web.OutgoingMessageBase = await this._formatMessage( diff --git a/packages/api/src/extensions/channels/web/settings.ts b/packages/api/src/extensions/channels/web/settings.ts index 97e92fe86..f4bf879c8 100644 --- a/packages/api/src/extensions/channels/web/settings.ts +++ b/packages/api/src/extensions/channels/web/settings.ts @@ -40,7 +40,7 @@ export default [ { group: WEB_CHANNEL_NAMESPACE, label: 'greeting_message', - value: 'Welcome! Ready to start a conversation with our chatbot?', + value: 'Welcome! Ready to start chatting with our chatbot?', type: SettingType.textarea, translatable: true, }, diff --git a/packages/api/src/extensions/index.ts b/packages/api/src/extensions/index.ts index 371db515c..e485bd407 100644 --- a/packages/api/src/extensions/index.ts +++ b/packages/api/src/extensions/index.ts @@ -25,3 +25,11 @@ export * from './helpers/llm-nlu/settings'; export * from './helpers/local-storage/index.helper'; export * from './helpers/local-storage/settings'; + +export * from './actions/messaging/text-message.action'; + +export * from './actions/messaging/quick-replies.action'; + +export * from './actions/messaging/buttons.action'; + +export * from './actions/messaging/attachment.action'; diff --git a/packages/api/src/helper/lib/base-flow-escape-helper.ts b/packages/api/src/helper/lib/base-flow-escape-helper.ts index 6a6db5fbe..db514cab0 100644 --- a/packages/api/src/helper/lib/base-flow-escape-helper.ts +++ b/packages/api/src/helper/lib/base-flow-escape-helper.ts @@ -5,7 +5,6 @@ */ import EventWrapper from '@/channel/lib/EventWrapper'; -import { BlockStub } from '@/chat/dto/block.dto'; import { FlowEscape, HelperName, HelperType } from '../types'; @@ -21,22 +20,22 @@ export default abstract class BaseFlowEscapeHelper< } /** - * Checks if the helper can handle the flow escape for the given block message. + * Checks if the helper can handle the flow escape for the given action payload. * - * @param _blockMessage - The block message to check. - * @returns - Whether the helper can handle the flow escape for the given block message. + * @param action - The action payload to check. + * @returns - Whether the helper can handle the flow escape for the given payload. */ - abstract canHandleFlowEscape(block: T): boolean; + abstract canHandleFlowEscape(action: T): boolean; /** * Adjudicates the flow escape event. * * @param _event - The event wrapper containing the event data. - * @param _block - The block associated with the event. + * @param action - The action associated with the event. * @returns - A promise that resolves to a FlowEscape.AdjudicationResult. */ - abstract adjudicate( + abstract adjudicate( event: EventWrapper, - block: T, + action: T, ): Promise; } diff --git a/packages/api/src/helper/lib/base-llm-helper.ts b/packages/api/src/helper/lib/base-llm-helper.ts index 9f210d500..9a5f088b8 100644 --- a/packages/api/src/helper/lib/base-llm-helper.ts +++ b/packages/api/src/helper/lib/base-llm-helper.ts @@ -54,8 +54,8 @@ export abstract class BaseLlmHelper< ): Promise; /** - * Send a chat completion request with the conversation history. - * You can use this same approach to start the conversation + * Send a chat completion request with the running dialogue history. + * You can use this same approach to start the exchange * using multi-shot or chain-of-thought prompting. * * @param prompt - The input text from the user diff --git a/packages/api/src/i18n/controllers/i18n.controller.ts b/packages/api/src/i18n/controllers/i18n.controller.ts index 6553032fd..538bb9182 100644 --- a/packages/api/src/i18n/controllers/i18n.controller.ts +++ b/packages/api/src/i18n/controllers/i18n.controller.ts @@ -6,14 +6,14 @@ import { Controller, Get } from '@nestjs/common'; +import { ActionService } from '@/actions/actions.service'; import { ChannelService } from '@/channel/channel.service'; import { HelperService } from '@/helper/helper.service'; -import { PluginService } from '@/plugins/plugins.service'; @Controller('i18n') export class I18nController { constructor( - private readonly pluginService: PluginService, + private readonly actionService: ActionService, private readonly helperService: HelperService, private readonly channelService: ChannelService, ) {} @@ -24,12 +24,21 @@ export class I18nController { */ @Get() getTranslations() { - const plugins = this.pluginService.getAll(); + const actions = this.actionService.getAll(); const helpers = this.helperService.getAll(); const channels = this.channelService.getAll(); + const extensions: Array<{ + getNamespace: () => string; + getTranslations: () => unknown; + }> = [...actions, ...helpers, ...channels]; - return [...plugins, ...helpers, ...channels].reduce((acc, curr) => { - acc[curr.getNamespace()] = curr.getTranslations(); + return extensions.reduce>((acc, curr) => { + const namespace = curr.getNamespace(); + const translations = curr.getTranslations(); + + if (translations !== undefined) { + acc[namespace] = translations; + } return acc; }, {}); diff --git a/packages/api/src/i18n/controllers/translation.controller.spec.ts b/packages/api/src/i18n/controllers/translation.controller.spec.ts index c1076bcf2..398c1e603 100644 --- a/packages/api/src/i18n/controllers/translation.controller.spec.ts +++ b/packages/api/src/i18n/controllers/translation.controller.spec.ts @@ -7,18 +7,16 @@ import { NotFoundException } from '@nestjs/common'; import { FindManyOptions } from 'typeorm'; -import { BlockRepository } from '@/chat/repositories/block.repository'; -import { ConversationRepository } from '@/chat/repositories/conversation.repository'; -import { BlockService } from '@/chat/services/block.service'; -import { PluginService } from '@/plugins/plugins.service'; import { NOT_FOUND_ID } from '@/utils/constants/mock'; import { installTranslationFixturesTypeOrm, translationFixtures, } from '@/utils/test/fixtures/translation'; import { I18nServiceProvider } from '@/utils/test/providers/i18n-service.provider'; +import { SettingServiceProvider } from '@/utils/test/providers/setting-service.provider'; import { closeTypeOrmConnections } from '@/utils/test/test'; import { buildTestingMocks } from '@/utils/test/utils'; +import { WorkflowService } from '@/workflow/services/workflow.service'; import { TranslationUpdateDto } from '../dto/translation.dto'; import { TranslationOrmEntity } from '../entities/translation.entity'; @@ -37,31 +35,13 @@ describe('TranslationController', () => { controllers: [TranslationController], providers: [ { - provide: BlockService, + provide: WorkflowService, useValue: { find: jest.fn().mockResolvedValue([]), - }, - }, - { - provide: BlockRepository, - useValue: { - find: jest.fn(), - findAll: jest.fn(), - }, - }, - { - provide: ConversationRepository, - useValue: { - find: jest.fn(), - }, - }, - { - provide: PluginService, - useValue: { - getPlugin: jest.fn(), - }, + } as Partial, }, I18nServiceProvider, + SettingServiceProvider, ], typeorm: [ { diff --git a/packages/api/src/i18n/controllers/translation.controller.ts b/packages/api/src/i18n/controllers/translation.controller.ts index 8c53e2158..179f3ad53 100644 --- a/packages/api/src/i18n/controllers/translation.controller.ts +++ b/packages/api/src/i18n/controllers/translation.controller.ts @@ -112,10 +112,9 @@ export class TranslationController extends BaseOrmController< }, {} as { [key: string]: string }, ); - // Scan Blocks - let strings = await this.translationService.getAllBlockStrings(); + // Scan workflows and settings + let strings = await this.translationService.getAllWorkflowStrings(); const settingStrings = await this.translationService.getSettingStrings(); - // Scan global settings strings = strings.concat(settingStrings); // Filter unique and not empty messages strings = strings.filter((str, pos) => { diff --git a/packages/api/src/i18n/i18n.module.ts b/packages/api/src/i18n/i18n.module.ts index 3f90bdf4e..5bad5e9e2 100644 --- a/packages/api/src/i18n/i18n.module.ts +++ b/packages/api/src/i18n/i18n.module.ts @@ -22,7 +22,7 @@ import { } from 'nestjs-i18n'; import { Observable } from 'rxjs'; -import { ChatModule } from '@/chat/chat.module'; +import { WorkflowModule } from '@/workflow/workflow.module'; import { I18nController } from './controllers/i18n.controller'; import { LanguageController } from './controllers/language.controller'; @@ -62,7 +62,7 @@ export class I18nModule extends NativeI18nModule { module: I18nModule, imports: (imports || []).concat([ TypeOrmModule.forFeature([LanguageOrmEntity, TranslationOrmEntity]), - ChatModule, + WorkflowModule, ]), controllers: (controllers || []).concat([ LanguageController, diff --git a/packages/api/src/i18n/services/i18n.service.ts b/packages/api/src/i18n/services/i18n.service.ts index 29c63a60a..20677249b 100644 --- a/packages/api/src/i18n/services/i18n.service.ts +++ b/packages/api/src/i18n/services/i18n.service.ts @@ -35,7 +35,7 @@ export class I18nService< lang = this.resolveLanguage(lang!); - // Translate block message, button text, ... + // Translate action message, button text, ... if (lang in this.dynamicTranslations) { if (key in this.dynamicTranslations[lang]) { if (this.dynamicTranslations[lang][key]) { diff --git a/packages/api/src/i18n/services/translation.service.spec.ts b/packages/api/src/i18n/services/translation.service.spec.ts index 182df6eff..0656b0630 100644 --- a/packages/api/src/i18n/services/translation.service.spec.ts +++ b/packages/api/src/i18n/services/translation.service.spec.ts @@ -4,26 +4,69 @@ * Full terms: see LICENSE.md. */ -import { BlockOptions } from '@/chat/types/options'; import { I18nService } from '@/i18n/services/i18n.service'; -import { BasePlugin } from '@/plugins/base-plugin.service'; -import { PluginService } from '@/plugins/plugins.service'; -import { PluginBlockTemplate } from '@/plugins/types'; -import { SettingType } from '@/setting/types'; import { SettingServiceProvider } from '@/utils/test/providers/setting-service.provider'; import { buildTestingMocks } from '@/utils/test/utils'; +import { Workflow } from '@/workflow/dto/workflow.dto'; +import { WorkflowService } from '@/workflow/services/workflow.service'; -import { Block } from '../../chat/dto/block.dto'; -import { BlockService } from '../../chat/services/block.service'; import { TranslationRepository } from '../repositories/translation.repository'; import { TranslationService } from '../services/translation.service'; describe('TranslationService', () => { let service: TranslationService; let i18nService: I18nService; - let pluginService: PluginService; + let workflowService: jest.Mocked; + + const workflowFixtures: Workflow[] = [ + { + id: 'workflow-1', + name: 'demo', + version: '1.0.0', + description: 'Workflow description', + definition: { + workflow: { description: 'Internal workflow description' }, + tasks: { + send_text: { + action: 'send_text_message', + description: 'Send greeting', + inputs: { + text: 'Hello user', + nested: { caption: 'Nested caption' }, + }, + }, + }, + flow: [{ do: 'send_text' }], + }, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as Workflow, + { + id: 'workflow-2', + name: 'secondary', + version: '0.1.0', + definition: { + tasks: { + ask_choice: { + action: 'send_quick_replies', + inputs: { + title: 'Pick one', + items: ['One', 'Two'], + }, + }, + }, + flow: [{ do: 'ask_choice' }], + }, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as Workflow, + ]; beforeEach(async () => { + workflowService = { + find: jest.fn().mockResolvedValue(workflowFixtures), + } as unknown as jest.Mocked; + const { getMocks } = await buildTestingMocks({ providers: [ TranslationService, @@ -41,36 +84,11 @@ describe('TranslationService', () => { ]), }, }, - { - provide: PluginService, - useValue: { - getPlugin: jest.fn(), - }, - }, - { - provide: BlockService, - useValue: { - find: jest.fn().mockResolvedValue([ - { - id: 'blockId', - message: ['Test message'], - options: { - fallback: { - message: ['Fallback message'], - }, - }, - } as Block, - ]), - }, - }, + { provide: WorkflowService, useValue: workflowService }, SettingServiceProvider, ], }); - [service, i18nService, pluginService] = await getMocks([ - TranslationService, - I18nService, - PluginService, - ]); + [service, i18nService] = await getMocks([TranslationService, I18nService]); }); it('should call refreshDynamicTranslations with translations from findAll', async () => { @@ -85,247 +103,26 @@ describe('TranslationService', () => { ]); }); - it('should return an array of strings from all blocks', async () => { - const strings = await service.getAllBlockStrings(); - expect(strings).toEqual(['Test message', 'Fallback message']); - }); - - it('should return plugin-related strings from block message with translatable args', async () => { - const block: Block = { - name: 'Ollama Plugin', - patterns: [], - outcomes: [], - assign_labels: [], - trigger_channels: [], - trigger_labels: [], - nextBlocks: [], - category: '51b4f7d2-ff67-433d-9c02-1f2345678901', - starts_conversation: false, - builtin: false, - capture_vars: [], - createdAt: new Date(), - updatedAt: new Date(), - id: '51b4f7d2-ff67-433d-9c02-1f2345678902', - position: { x: 702, y: 321.8333282470703 }, - message: { - plugin: 'ollama-plugin', - args: { - model: 'String 1', - context: ['String 2', 'String 3'], - }, - }, - options: {}, - attachedBlock: null, - }; - - class MockPlugin extends BasePlugin { - template: PluginBlockTemplate = { name: 'Ollama Plugin' }; - - name: `${string}-plugin`; - - type: any; - - private settings: { - label: string; - group: string; - type: SettingType; - value: any; - translatable: boolean; - }[]; - - constructor() { - super('ollama-plugin', pluginService); - this.name = 'ollama-plugin'; - this.type = 'block'; - this.settings = [ - { - label: 'model', - group: 'default', - type: SettingType.text, - value: 'llama3.2', - translatable: false, - }, - { - label: 'context', - group: 'default', - type: SettingType.multiple_text, - value: ['Answer the user QUESTION using the DOCUMENTS text above.'], - translatable: true, - }, - ]; - } - - // Implementing the 'getPath' method (with a mock return value) - getPath() { - // Return a mock path - return '/mock/path'; - } - - async getDefaultSettings() { - return this.settings; - } - } - - // Create an instance of the mock plugin - const mockedPlugin = new MockPlugin(); - - jest - .spyOn(pluginService, 'getPlugin') - .mockImplementation(() => mockedPlugin); - - const result = await service.getBlockStrings(block); - expect(result).toEqual(['String 2', 'String 3']); + it('should return an array of strings from all workflows', async () => { + const strings = await service.getAllWorkflowStrings(); + + expect(strings).toEqual( + expect.arrayContaining([ + 'Workflow description', + 'Internal workflow description', + 'Send greeting', + 'Hello user', + 'Nested caption', + 'Pick one', + 'One', + 'Two', + ]), + ); + expect(strings).not.toContain('send_text_message'); }); it('should return the settings translation strings', async () => { const strings = await service.getSettingStrings(); expect(strings).toEqual(['Global fallback message']); }); - - it('should return an array of strings from a block with a quick reply message', async () => { - const block = { - id: 'blockId', - name: 'Test Block', - category: 'Test Category', - position: { x: 0, y: 0 }, - message: { - text: 'Test message', - quickReplies: [ - { - title: 'Quick reply 1', - }, - { - title: 'Quick reply 2', - }, - ], - }, - options: { - fallback: { - active: true, - message: ['Fallback message'], - max_attempts: 3, - } as BlockOptions, - }, - createdAt: new Date(), - updatedAt: new Date(), - } as Block; - const strings = await service.getBlockStrings(block); - expect(strings).toEqual([ - 'Test message', - 'Quick reply 1', - 'Quick reply 2', - 'Fallback message', - ]); - }); - - it('should return an array of strings from a block with a button message', async () => { - const block = { - id: 'blockId', - name: 'Test Block', - category: 'Test Category', - position: { x: 0, y: 0 }, - message: { - text: 'Test message', - buttons: [ - { - title: 'Button 1', - }, - { - title: 'Button 2', - }, - ], - }, - options: { - fallback: { - active: true, - message: ['Fallback message'], - max_attempts: 3, - } as BlockOptions, - }, - createdAt: new Date(), - updatedAt: new Date(), - } as Block; - const strings = await service.getBlockStrings(block); - expect(strings).toEqual([ - 'Test message', - 'Button 1', - 'Button 2', - 'Fallback message', - ]); - }); - - it('should return an array of strings from a block with a text message', async () => { - const block = { - id: 'blockId', - name: 'Test Block', - category: 'Test Category', - position: { x: 0, y: 0 }, - message: ['Test message'], // Text message as an array - options: { - fallback: { - active: true, - message: ['Fallback message'], - max_attempts: 3, - } as BlockOptions, - }, - createdAt: new Date(), - updatedAt: new Date(), - } as 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', async () => { - const block = { - id: 'blockId', - name: 'Test Block', - category: 'Test Category', - position: { x: 0, y: 0 }, - message: { - text: 'Test message', // Nested text message - }, - options: { - fallback: { - active: true, - message: ['Fallback message'], - max_attempts: 3, - } as BlockOptions, - }, - createdAt: new Date(), - updatedAt: new Date(), - } as Block; - const strings = await service.getBlockStrings(block); - expect(strings).toEqual(['Test message', 'Fallback message']); - }); - - it('should handle different message formats in getBlockStrings', async () => { - // Covers lines 54-60, 65 - - // Test with an array message (line 54-57) - const block1 = { - id: 'blockId1', - message: ['This is a text message'], - options: { fallback: { message: ['Fallback message'] } }, - } as Block; - const strings1 = await service.getBlockStrings(block1); - expect(strings1).toEqual(['This is a text message', 'Fallback message']); - - // Test with an object message (line 58-60) - const block2 = { - id: 'blockId2', - message: { text: 'Another text message' }, - options: { fallback: { message: ['Fallback message'] } }, - } as Block; - const strings2 = await service.getBlockStrings(block2); - expect(strings2).toEqual(['Another text message', 'Fallback message']); - - // Test a block without a fallback (line 65) - const block3 = { - id: 'blockId3', - message: { text: 'Another test message' }, - options: {}, - } as Block; - const strings3 = await service.getBlockStrings(block3); - expect(strings3).toEqual(['Another test message']); - }); }); diff --git a/packages/api/src/i18n/services/translation.service.ts b/packages/api/src/i18n/services/translation.service.ts index f1966e61e..955a82651 100644 --- a/packages/api/src/i18n/services/translation.service.ts +++ b/packages/api/src/i18n/services/translation.service.ts @@ -8,14 +8,10 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { I18nService } from '@/i18n/services/i18n.service'; -import { PluginService } from '@/plugins/plugins.service'; -import { PluginType } from '@/plugins/types'; import { SettingService } from '@/setting/services/setting.service'; -import { SettingType } from '@/setting/types'; import { BaseOrmService } from '@/utils/generics/base-orm.service'; +import { WorkflowService } from '@/workflow/services/workflow.service'; -import { Block } from '../../chat/dto/block.dto'; -import { BlockService } from '../../chat/services/block.service'; import { TranslationDtoConfig, TranslationTransformerDto, @@ -31,9 +27,8 @@ export class TranslationService extends BaseOrmService< > { constructor( repository: TranslationRepository, - private readonly blockService: BlockService, + private readonly workflowService: WorkflowService, private readonly settingService: SettingService, - private readonly pluginService: PluginService, private readonly i18n: I18nService, ) { super(repository); @@ -46,137 +41,67 @@ export class TranslationService extends BaseOrmService< } /** - * Return any available string inside a given block (message, button titles, fallback messages, ...) + * Collect user-facing strings declared inside a workflow definition by + * recursively traversing its content and extracting string leaves while + * skipping structural keys (action identifiers, ids, etc). * - * @param block - The block to parse - * - * @returns An array of strings + * @param node - Arbitrary JSON node inside the workflow definition. */ - async getBlockStrings(block: Block): Promise { - let strings: string[] = []; - - if (Array.isArray(block.message)) { - // Text Messages - strings = strings.concat(block.message); - } else if (typeof block.message === 'object') { - if ('plugin' in block.message) { - const plugin = this.pluginService.getPlugin( - PluginType.block, - block.message.plugin, - ); - const defaultSettings = (await plugin?.getDefaultSettings()) || []; - const filteredSettings = defaultSettings.filter( - ({ translatable, type }) => - [ - SettingType.text, - SettingType.textarea, - SettingType.multiple_text, - ].includes(type) && - (translatable === undefined || translatable === true), - ); - const settingTypeMap = new Map( - filteredSettings.map((setting) => [setting.label, setting.type]), - ); + private collectStrings(node: unknown, keyPath: string[] = []): string[] { + if (node == null) { + return []; + } - for (const [key, value] of Object.entries(block.message.args)) { - const settingType = settingTypeMap.get(key); - - switch (settingType) { - case SettingType.multiple_text: - if (Array.isArray(value)) { - strings = strings.concat(value); - } else if (typeof value === 'string') { - this.logger.warn( - `The plugin ${plugin?.name} setting '${key}' is incompatible with the settings.ts`, - ); - this.logger.warn( - `Expected type "array" received type "string"`, - ); - strings = strings.concat([value]); - } else { - this.logger.warn( - `Setting expected type "array" is different from the value type "${typeof value}"`, - ); - } - break; - case SettingType.text: - case SettingType.textarea: - if (typeof value === 'string') { - strings.push(value); - } else if (Array.isArray(value)) { - this.logger.warn( - `The plugin ${plugin?.name} setting '${key}' is incompatible with the settings.ts`, - ); - this.logger.warn( - 'Expected type "string" received type "array"', - ); - strings.push(...value.flat()); - } else { - this.logger.warn( - `Setting expected type "string" is different from the value type "${typeof value}"`, - ); - } - break; - default: - break; - } - } - } else if ('text' in block.message && Array.isArray(block.message.text)) { - // array of text - strings = strings.concat(block.message.text); - } else if ( - 'text' in block.message && - typeof block.message.text === 'string' - ) { - // text - strings.push(block.message.text); - } - if ( - 'quickReplies' in block.message && - Array.isArray(block.message.quickReplies) && - block.message.quickReplies.length > 0 - ) { - // Quick replies - strings = strings.concat( - block.message.quickReplies.map((qr) => qr.title), - ); - } else if ( - 'buttons' in block.message && - Array.isArray(block.message.buttons) && - block.message.buttons.length > 0 - ) { - // Buttons - strings = strings.concat(block.message.buttons.map((btn) => btn.title)); - } + if (typeof node === 'string') { + const trimmed = node.trim(); + + return trimmed ? [trimmed] : []; } - // Add fallback messages - if ( - block.options && - 'fallback' in block.options && - block.options.fallback && - 'message' in block.options.fallback && - Array.isArray(block.options.fallback.message) - ) { - strings = strings.concat(block.options.fallback.message); + + if (Array.isArray(node)) { + return node.flatMap((value) => this.collectStrings(value, keyPath)); } - return strings; + if (typeof node === 'object') { + return Object.entries(node as Record).flatMap( + ([key, value]) => { + return this.shouldSkipKey(key) + ? [] + : this.collectStrings(value, keyPath.concat(key)); + }, + ); + } + + return []; } /** - * Return any available string inside a block (message, button titles, fallback messages, ...) + * Return any available string inside workflow definitions (task inputs, + * descriptions, etc.). * * @returns A promise of all strings available in a array */ - async getAllBlockStrings(): Promise { - const blocks = await this.blockService.find({}); - if (blocks.length === 0) { - return []; - } + async getAllWorkflowStrings(): Promise { + const workflows = await this.workflowService.find({}); const allStrings: string[] = []; - for (const block of blocks) { - const strings = await this.getBlockStrings(block); - allStrings.push(...strings); + + for (const workflow of workflows) { + if (workflow.description) { + allStrings.push(workflow.description); + } + + if (!workflow.definition) { + continue; + } + + try { + allStrings.push(...this.collectStrings(workflow.definition)); + } catch (err) { + this.logger.warn( + `Unable to collect strings from workflow ${workflow.id}`, + err, + ); + } } return allStrings; @@ -210,4 +135,13 @@ export class TranslationService extends BaseOrmService< handleTranslationsUpdate() { this.resetI18nTranslations(); } + + /** + * Skip structural keys when extracting strings from workflow definitions. + */ + private shouldSkipKey(key: string): boolean { + const lowered = key.toLowerCase(); + + return ['action', 'do', 'next', 'id', 'name', 'version'].includes(lowered); + } } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index ca7eeccb8..eb66426dc 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -20,6 +20,8 @@ export * from './seeder'; export * from './swagger'; // Domain modules +export * from './actions'; + export * from './analytics'; export * from './attachment'; @@ -50,14 +52,14 @@ export * from './migration'; export * from './nlp'; -export * from './plugins'; - export * from './setting'; export * from './user'; export * from './websocket'; +export * from './workflow'; + export * from './utils'; // Shared configuration diff --git a/packages/api/src/nlp/dto/nlp-entity.dto.ts b/packages/api/src/nlp/dto/nlp-entity.dto.ts index 20b18f61d..0b85b012d 100644 --- a/packages/api/src/nlp/dto/nlp-entity.dto.ts +++ b/packages/api/src/nlp/dto/nlp-entity.dto.ts @@ -115,7 +115,7 @@ export class NlpEntityCreateDto { @ApiPropertyOptional({ description: - 'Weight used to determine the next block to trigger in the flow', + 'Weight used to determine the next action to trigger in the flow', type: Number, minimum: 1, }) diff --git a/packages/api/src/nlp/services/nlp-entity.service.ts b/packages/api/src/nlp/services/nlp-entity.service.ts index f0d8308a6..bab5b4a87 100644 --- a/packages/api/src/nlp/services/nlp-entity.service.ts +++ b/packages/api/src/nlp/services/nlp-entity.service.ts @@ -54,8 +54,8 @@ export class NlpEntityService extends BaseOrmService< /** * Updates the `weight` field of a specific NLP entity by its ID. * - * This method is part of the NLP-based blocks prioritization strategy. - * The weight influences the scoring of blocks when multiple blocks match a user's input. + * This method is part of the NLP-based action prioritization strategy. + * The weight influences the scoring of actions when multiple candidates match a user's input. * @param id - The unique identifier of the entity to update. * @param updatedWeight - The new weight to assign. Must be a positive number. * @throws Error if the weight is not a positive number. diff --git a/packages/api/src/plugins/base-block-plugin.ts b/packages/api/src/plugins/base-block-plugin.ts deleted file mode 100644 index 61207db48..000000000 --- a/packages/api/src/plugins/base-block-plugin.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import path from 'path'; - -import { Injectable } from '@nestjs/common'; - -import { Block, BlockFull } from '@/chat/dto/block.dto'; -import { Context } from '@/chat/types/context'; -import { StdOutgoingEnvelope } from '@/chat/types/message'; - -import { BasePlugin } from './base-plugin.service'; -import { PluginService } from './plugins.service'; -import { - PluginBlockTemplate, - PluginEffects, - PluginName, - PluginSetting, - PluginType, -} from './types'; - -@Injectable() -export abstract class BaseBlockPlugin< - T extends PluginSetting[], -> extends BasePlugin { - public readonly type: PluginType = PluginType.block; - - private readonly settings: T; - - constructor(name: PluginName, pluginService: PluginService) { - super(name, pluginService); - // eslint-disable-next-line @typescript-eslint/no-require-imports - this.settings = require(path.join(this.getPath(), 'settings')).default; - } - - getDefaultSettings(): Promise | T { - return this.settings; - } - - abstract template: PluginBlockTemplate; - - effects?: PluginEffects; - - abstract process( - block: Block | BlockFull, - context: Context, - convId?: string, - ): Promise; - - protected getArguments(block: Block) { - if ('args' in block.message) { - return ( - Object.entries(block.message.args) - // Filter out old settings - .filter( - ([argKey]) => - this.settings.findIndex(({ label }) => label === argKey) !== -1, - ) - .reduce( - (acc, [k, v]) => ({ - ...acc, - [k]: v, - }), - {} as SettingObject, - ) - ); - } - throw new Error(`Block ${block.name} does not have any arguments.`); - } -} diff --git a/packages/api/src/plugins/base-event-plugin.ts b/packages/api/src/plugins/base-event-plugin.ts deleted file mode 100644 index fe4782b35..000000000 --- a/packages/api/src/plugins/base-event-plugin.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; - -import { BasePlugin } from './base-plugin.service'; -import { PluginService } from './plugins.service'; -import { PluginName, PluginType } from './types'; - -@Injectable() -export abstract class BaseEventPlugin extends BasePlugin { - public readonly type: PluginType = PluginType.event; - - constructor(name: PluginName, pluginService: PluginService) { - super(name, pluginService); - } -} diff --git a/packages/api/src/plugins/base-plugin.service.ts b/packages/api/src/plugins/base-plugin.service.ts deleted file mode 100644 index d748ae2db..000000000 --- a/packages/api/src/plugins/base-plugin.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable, OnModuleInit } from '@nestjs/common'; - -import { Extension } from '@/utils/generics/extension'; - -import { PluginService } from './plugins.service'; -import { PluginName, PluginType } from './types'; - -@Injectable() -export abstract class BasePlugin extends Extension implements OnModuleInit { - public readonly type: PluginType; - - constructor( - public readonly name: PluginName, - private pluginService: PluginService, - ) { - super(name); - } - - async onModuleInit() { - await super.onModuleInit(); - this.pluginService.setPlugin(this.type, this.name, this); - } -} diff --git a/packages/api/src/plugins/index.ts b/packages/api/src/plugins/index.ts deleted file mode 100644 index 93b525748..000000000 --- a/packages/api/src/plugins/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -export * from './base-block-plugin'; - -export * from './base-event-plugin'; - -export * from './base-plugin.service'; - -export * from './map-types'; - -export * from './plugins.module'; - -export * from './plugins.service'; - -export * from './types'; diff --git a/packages/api/src/plugins/map-types.ts b/packages/api/src/plugins/map-types.ts deleted file mode 100644 index 9153761d4..000000000 --- a/packages/api/src/plugins/map-types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { BaseBlockPlugin } from './base-block-plugin'; -import { BaseEventPlugin } from './base-event-plugin'; -import { BasePlugin } from './base-plugin.service'; -import { PluginType } from './types'; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const PLUGIN_TYPE_MAP = { - [PluginType.event]: BaseEventPlugin, - [PluginType.block]: BaseBlockPlugin, -} as const; - -export type PluginTypeMap = typeof PLUGIN_TYPE_MAP; - -export type PluginInstance = InstanceType< - PluginTypeMap[T] -> & - BasePlugin; diff --git a/packages/api/src/plugins/plugins.module.ts b/packages/api/src/plugins/plugins.module.ts deleted file mode 100644 index b456a1eac..000000000 --- a/packages/api/src/plugins/plugins.module.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { HttpModule } from '@nestjs/axios'; -import { Global, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; - -import { AttachmentModule } from '@/attachment/attachment.module'; -import { ChatModule } from '@/chat/chat.module'; -import { BlockOrmEntity } from '@/chat/entities/block.entity'; -import { CmsModule } from '@/cms/cms.module'; -import { NlpModule } from '@/nlp/nlp.module'; - -import { PluginService } from './plugins.service'; - -@InjectDynamicProviders( - // Built-in core plugins - 'node_modules/@hexabot-ai/api/dist/extensions/plugins/**/*.plugin.js', - // Community extensions installed via npm - 'node_modules/hexabot-plugin-*/**/*.plugin.js', - // Custom & under dev plugins - 'dist/extensions/plugins/**/*.plugin.js', -) -@Global() -@Module({ - imports: [ - TypeOrmModule.forFeature([BlockOrmEntity]), - CmsModule, - AttachmentModule, - ChatModule, - HttpModule, - NlpModule, - ], - providers: [PluginService], - exports: [PluginService], -}) -export class PluginsModule {} diff --git a/packages/api/src/plugins/plugins.service.spec.ts b/packages/api/src/plugins/plugins.service.spec.ts deleted file mode 100644 index 5db3492a5..000000000 --- a/packages/api/src/plugins/plugins.service.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { LoggerModule } from '@/logger/logger.module'; -import { DummyPlugin } from '@/utils/test/dummy/dummy.plugin'; -import { buildTestingMocks } from '@/utils/test/utils'; - -import { BaseBlockPlugin } from './base-block-plugin'; -import { PluginService } from './plugins.service'; -import { PluginType } from './types'; - -describe('PluginsService', () => { - let pluginsService: PluginService; - let dummyPlugin: DummyPlugin; - - beforeAll(async () => { - const { getMocks } = await buildTestingMocks({ - providers: [PluginService, DummyPlugin], - imports: [LoggerModule], - }); - [pluginsService, dummyPlugin] = await getMocks([ - PluginService, - DummyPlugin, - ]); - await dummyPlugin.onModuleInit(); - }); - - afterAll(jest.clearAllMocks); - describe('getAll', () => { - it('should return an array of instances of base plugin', () => { - const result = pluginsService.getAllByType(PluginType.block); - expect(result.every((p) => p instanceof BaseBlockPlugin)).toBeTruthy(); - }); - }); - - describe('getPlugin', () => { - it('should return the required plugin', () => { - const result = pluginsService.getPlugin(PluginType.block, 'dummy-plugin'); - expect(result).toBeInstanceOf(DummyPlugin); - }); - }); -}); diff --git a/packages/api/src/plugins/plugins.service.ts b/packages/api/src/plugins/plugins.service.ts deleted file mode 100644 index 8430c17b8..000000000 --- a/packages/api/src/plugins/plugins.service.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable, InternalServerErrorException } from '@nestjs/common'; - -import { BasePlugin } from './base-plugin.service'; -import { PluginInstance } from './map-types'; -import { PluginName, PluginType } from './types'; - -/** - * @summary Service for managing and retrieving plugins. - * - * Plugins are dynamically loaded Nestjs providers. - * They offer additional features to the app (example : custom blocks) - * - * @description - * The `PluginService` is responsible for managing a registry of plugins that extend from a base plugin type. - * It provides methods for adding, retrieving, and finding plugins based on a key or plugin identifier. - * This service is generic and supports a default plugin type `BaseBlockPlugin`. - * - * @typeparam T - The plugin type, which extends from `BasePlugin`. By default, it uses `BaseBlockPlugin`. - */ -@Injectable() -export class PluginService { - /** - * The registry of plugins, stored as a map where the first key is the type of plugin, - * the second key is the name of the plugin and the value is a plugin of type `T`. - */ - private registry: Map> = new Map( - Object.keys(PluginType).map((t) => [t as PluginType, new Map()]), - ); - - constructor() {} - - /** - * Registers a plugin with a given name. - * - * @param name The unique identifier for the plugin. - * @param plugin The plugin instance to register. - */ - public setPlugin(type: PluginType, name: PluginName, plugin: T) { - const registry = this.registry.get(type) as Map; - if (registry.has(name)) { - throw new InternalServerErrorException( - `Unable to setPlugin() with name ${name} of type ${type} (possible duplicate)`, - ); - } - registry.set(name, plugin); - } - - /** - * Retrieves all registered plugins by as an array. - * - * @returns An array containing all the registered plugins. - */ - public getAllByType(type: PT): PluginInstance[] { - const registry = this.registry.get(type) as Map; - - return Array.from(registry.values()) as PluginInstance[]; - } - - /** - * Retrieves all registered plugins as an array. - * - * @returns An array containing all the registered plugins. - */ - public getAll(): T[] { - return Array.from(this.registry.values()) // Get all the inner maps - .flatMap((innerMap) => Array.from(innerMap.values())); // Flatten and get the values from each inner map - } - - /** - * Retrieves a plugin based on its key. - * - * @param name The key used to register the plugin. - * - * @returns The plugin associated with the given key, or `undefined` if not found. - */ - public getPlugin(type: PT, name: PluginName) { - const registry = this.registry.get(type) as Map; - const plugin = registry.get(name); - - return plugin ? (plugin as PluginInstance) : undefined; - } - - /** - * Finds a plugin by its internal `id` property. - * - * @param name The unique `id` of the plugin to find. - * - * @returns The plugin with the matching `id`, or `undefined` if no plugin is found. - */ - public findPlugin(type: PT, name: PluginName) { - return this.getAllByType(type).find((plugin) => { - return plugin.name === name; - }); - } -} diff --git a/packages/api/src/plugins/types.ts b/packages/api/src/plugins/types.ts deleted file mode 100644 index 36744db92..000000000 --- a/packages/api/src/plugins/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { ChannelEvent } from '@/channel/lib/EventWrapper'; -import { Block, BlockCreateDto } from '@/chat/dto/block.dto'; -import { Conversation } from '@/chat/dto/conversation.dto'; -import { AnySetting, ExtensionSetting } from '@/setting/types'; - -export type PluginName = `${string}-plugin`; - -export enum PluginType { - event = 'event', - block = 'block', -} - -type BlockAttrs = Partial & { name: string }; - -export type PluginSetting = ExtensionSetting< - { - weight?: number; - }, - AnySetting, - 'id' | 'createdAt' | 'updatedAt' | 'weight' ->; - -export type PluginBlockTemplate = Omit< - BlockAttrs, - 'message' | 'position' | 'builtin' | 'attachedBlock' ->; - -export type PluginEffects = { - onStoreContextData?: ( - convo: Conversation, - nextBlock: Block, - event: ChannelEvent, - captureVars: any, - ) => void; -}; diff --git a/packages/api/src/seeder.ts b/packages/api/src/seeder.ts index bcb529dee..c4cedeb42 100644 --- a/packages/api/src/seeder.ts +++ b/packages/api/src/seeder.ts @@ -6,8 +6,6 @@ import { INestApplicationContext } from '@nestjs/common'; -import { CategorySeeder } from './chat/seeds/category.seed'; -import { categoryModels } from './chat/seeds/category.seed-model'; import { ContextVarSeeder } from './chat/seeds/context-var.seed'; import { contextVarModels } from './chat/seeds/context-var.seed-model'; import { LanguageSeeder } from './i18n/seeds/language.seed'; @@ -37,7 +35,6 @@ import { userModels } from './user/seeds/user.seed-model'; export async function seedDatabase(app: INestApplicationContext) { const logger = await app.resolve(LoggerService); const modelSeeder = app.get(ModelSeeder); - const categorySeeder = app.get(CategorySeeder); const contextVarSeeder = app.get(ContextVarSeeder); const roleSeeder = app.get(RoleSeeder); const settingSeeder = app.get(SettingSeeder); @@ -117,14 +114,6 @@ export async function seedDatabase(app: INestApplicationContext) { throw e; } - // Seed categories - try { - await categorySeeder.seed(categoryModels); - } catch (e) { - logger.error('Unable to seed the database with categories!'); - throw e; - } - // Seed context vars try { await contextVarSeeder.seed(contextVarModels); diff --git a/packages/api/src/setting/seeds/setting.seed-model.ts b/packages/api/src/setting/seeds/setting.seed-model.ts index 06fe2645c..33ef25db1 100644 --- a/packages/api/src/setting/seeds/setting.seed-model.ts +++ b/packages/api/src/setting/seeds/setting.seed-model.ts @@ -83,21 +83,6 @@ export const DEFAULT_SETTINGS = [ type: SettingType.checkbox, weight: 5, }, - { - group: 'chatbot_settings', - label: 'fallback_block', - value: '', - options: [], - type: SettingType.select, - config: { - multiple: false, - allowCreate: false, - entity: 'Block', - idKey: 'id', - labelKey: 'name', - }, - weight: 6, - }, { group: 'chatbot_settings', label: 'fallback_message', @@ -106,7 +91,7 @@ export const DEFAULT_SETTINGS = [ "I'm really sorry but i don't quite understand what you are saying :(", ] as string[], type: SettingType.multiple_text, - weight: 7, + weight: 6, translatable: true, }, { diff --git a/packages/api/src/user/seeds/model.seed-model.ts b/packages/api/src/user/seeds/model.seed-model.ts index b62148cf7..1b1a3472d 100644 --- a/packages/api/src/user/seeds/model.seed-model.ts +++ b/packages/api/src/user/seeds/model.seed-model.ts @@ -62,16 +62,6 @@ export const modelModels: ModelCreateDto[] = [ identity: 'permission', attributes: {}, }, - { - name: 'Block', - identity: 'block', - attributes: {}, - }, - { - name: 'Category', - identity: 'category', - attributes: {}, - }, { name: 'Label', identity: 'label', @@ -87,11 +77,6 @@ export const modelModels: ModelCreateDto[] = [ identity: 'contextvar', attributes: {}, }, - { - name: 'Conversation', - identity: 'conversation', - attributes: {}, - }, { name: 'Message', identity: 'message', @@ -112,6 +97,16 @@ export const modelModels: ModelCreateDto[] = [ identity: 'translation', attributes: {}, }, + { + name: 'Workflow', + identity: 'workflow', + attributes: {}, + }, + { + name: 'WorkflowRun', + identity: 'workflowrun', + attributes: {}, + }, { name: 'BotStats', identity: 'botstats', diff --git a/packages/api/src/user/types/model.type.ts b/packages/api/src/user/types/model.type.ts index 85a9545a3..df9e3bd10 100644 --- a/packages/api/src/user/types/model.type.ts +++ b/packages/api/src/user/types/model.type.ts @@ -16,16 +16,15 @@ export type TModel = | 'user' | 'role' | 'permission' - | 'block' - | 'category' | 'label' | 'labelgroup' | 'contextvar' - | 'conversation' | 'message' | 'subscriber' | 'language' | 'translation' | 'botstats' | 'menu' + | 'workflow' + | 'workflowrun' | 'model'; diff --git a/packages/api/src/utils/helpers/clone.ts b/packages/api/src/utils/helpers/clone.ts new file mode 100644 index 000000000..832478dec --- /dev/null +++ b/packages/api/src/utils/helpers/clone.ts @@ -0,0 +1,27 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +/** + * Deep clones plain data structures using `structuredClone` when available, + * falling back to JSON serialization otherwise. + */ +export const cloneObject = (value: T): T => { + if (value == null) { + return value; + } + + const structuredCloneFn = ( + globalThis as { + structuredClone?: (data: U) => U; + } + ).structuredClone; + + if (structuredCloneFn) { + return structuredCloneFn(value); + } + + return JSON.parse(JSON.stringify(value)) as T; +}; diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index d9577cce7..e16d3508a 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -38,6 +38,8 @@ export * from './helpers/URL'; export * from './helpers/avatar'; +export * from './helpers/clone'; + export * from './helpers/flatten'; export * from './helpers/fs'; diff --git a/packages/api/src/utils/pipes/sanitize-query.pipe.ts b/packages/api/src/utils/pipes/sanitize-query.pipe.ts index 89d914a11..5e4550386 100644 --- a/packages/api/src/utils/pipes/sanitize-query.pipe.ts +++ b/packages/api/src/utils/pipes/sanitize-query.pipe.ts @@ -12,7 +12,7 @@ import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'; * and limits the length of the input to a specified maximum (default 1000 characters). */ -// TODO: Centralize the maximum character limit for block text messages into an exportable constant to ensure consistency across the codebase +// TODO: Centralize the maximum character limit for message text into an exportable constant to ensure consistency across the codebase const MAX_BLOCK_TEXT_MESSAGE_LENGTH = 1000; @Injectable() diff --git a/packages/api/src/utils/test/dummy/dummy.action.ts b/packages/api/src/utils/test/dummy/dummy.action.ts new file mode 100644 index 000000000..0b7b73682 --- /dev/null +++ b/packages/api/src/utils/test/dummy/dummy.action.ts @@ -0,0 +1,46 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { + SettingsSchema as BaseSettingsSchema, + BaseWorkflowContext, +} from '@hexabot-ai/agentic'; +import { z } from 'zod'; + +import { createAction } from '@/actions/create-action'; + +const InputSchema = z.object({ + message: z.string(), +}); +type Input = z.infer; + +const OutputSchema = z.object({ + echoed: z.string(), +}); +type Output = z.infer; + +const ActionSettingsSchema = BaseSettingsSchema.extend({ + prefix: z.string().default('Echo: '), +}); +type ActionSettings = z.infer; + +export const DummyAction = createAction< + Input, + Output, + BaseWorkflowContext, + ActionSettings +>({ + name: 'dummy_action', + description: 'Echoes the provided message', + inputSchema: InputSchema, + outputSchema: OutputSchema, + settingsSchema: ActionSettingsSchema, + async execute({ input, settings }) { + const prefix = settings.prefix ?? ''; + + return { echoed: `${prefix}${input.message}` }; + }, +}); diff --git a/packages/api/src/utils/test/dummy/dummy.plugin.ts b/packages/api/src/utils/test/dummy/dummy.plugin.ts deleted file mode 100644 index 5f049a603..000000000 --- a/packages/api/src/utils/test/dummy/dummy.plugin.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Injectable } from '@nestjs/common'; - -import { - OutgoingMessageFormat, - StdOutgoingTextEnvelope, -} from '@/chat/types/message'; -import { BaseBlockPlugin } from '@/plugins/base-block-plugin'; -import { PluginService } from '@/plugins/plugins.service'; -import { PluginBlockTemplate, PluginSetting } from '@/plugins/types'; - -@Injectable() -export class DummyPlugin extends BaseBlockPlugin { - template: PluginBlockTemplate = { name: 'Dummy Plugin' }; - - constructor(pluginService: PluginService) { - super('dummy-plugin', pluginService); - - this.effects = { - onStoreContextData: () => {}, - }; - } - - getPath(): string { - return __dirname; - } - - async process() { - const envelope: StdOutgoingTextEnvelope = { - format: OutgoingMessageFormat.text, - message: { - text: 'Hello world !', - }, - }; - - return envelope; - } -} diff --git a/packages/api/src/utils/test/fixtures/block.ts b/packages/api/src/utils/test/fixtures/block.ts deleted file mode 100644 index 4374b1426..000000000 --- a/packages/api/src/utils/test/fixtures/block.ts +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { DataSource, DeepPartial } from 'typeorm'; - -import { Block, BlockCreateDto } from '@/chat/dto/block.dto'; -import { BlockOrmEntity } from '@/chat/entities/block.entity'; -import { CategoryOrmEntity } from '@/chat/entities/category.entity'; -import { FileType } from '@/chat/types/attachment'; -import { ButtonType } from '@/chat/types/button'; -import { QuickReplyType } from '@/chat/types/quick-reply'; - -import { getFixturesWithDefaultValues } from '../defaultValues'; -import { FixturesTypeBuilder } from '../types'; - -type TBlockFixtures = FixturesTypeBuilder; - -export const blockDefaultValues: TBlockFixtures['defaultValues'] = { - options: {}, - nextBlocks: [], - capture_vars: [], - assign_labels: [], - trigger_labels: [], - trigger_channels: [], - builtin: false, - starts_conversation: false, -}; - -export const blocks: TBlockFixtures['values'][] = [ - { - name: 'hasNextBlocks', - patterns: ['Hi'], - outcomes: [], - category: null, - options: { - typing: 0, - fallback: { - active: false, - max_attempts: 1, - message: [], - }, - }, - message: ['Hi back !'], - position: { - x: 0, - y: 0, - }, - }, - { - name: 'hasPreviousBlocks', - patterns: ['colors'], - outcomes: [], - category: null, - options: { - typing: 0, - fallback: { - active: false, - max_attempts: 1, - message: [], - }, - }, - message: { - text: 'What"s your favorite color?', - quickReplies: [ - { - content_type: QuickReplyType.text, - title: 'Green', - payload: 'Green', - }, - { - content_type: QuickReplyType.text, - title: 'Yellow', - payload: 'Yellow', - }, - { - content_type: QuickReplyType.text, - title: 'Red', - payload: 'Red', - }, - ], - }, - position: { - x: 0, - y: 1, - }, - }, - { - name: 'buttons', - patterns: ['about'], - outcomes: [], - category: null, - options: { - typing: 0, - fallback: { - active: false, - max_attempts: 1, - message: [], - }, - }, - message: { - text: 'What would you like to know about us?', - buttons: [ - { - type: ButtonType.postback, - title: 'Vision', - payload: 'Vision', - }, - { - type: ButtonType.postback, - title: 'Values', - payload: 'Values', - }, - { - type: ButtonType.postback, - title: 'Approach', - payload: 'Approach', - }, - ], - }, - position: { - x: 0, - y: 2, - }, - }, - { - name: 'attachment', - patterns: ['image'], - outcomes: [], - category: null, - options: { - typing: 0, - fallback: { - active: false, - max_attempts: 1, - message: [], - }, - }, - message: { - attachment: { - type: FileType.image, - payload: { - id: '1', - }, - }, - quickReplies: [], - }, - position: { - x: 0, - y: 3, - }, - }, - { - name: 'test', - patterns: ['yes'], - outcomes: [], - category: null, - //to be verified - options: { - typing: 0, - fallback: { - active: false, - max_attempts: 1, - message: [], - }, - }, - message: [':)', ':D', ';)'], - position: { - x: 36, - y: 78, - }, - }, -]; - -export const blockFixtures = getFixturesWithDefaultValues< - TBlockFixtures['values'] ->({ - fixtures: blocks, - defaultValues: blockDefaultValues, -}); - -const findBlocks = async (dataSource: DataSource) => - await dataSource.getRepository(BlockOrmEntity).find({ - relations: [ - 'category', - 'trigger_labels', - 'assign_labels', - 'nextBlocks', - 'previousBlocks', - 'attachedBlock', - 'attachedToBlock', - ], - }); - -export const installBlockFixturesTypeOrm = async (dataSource: DataSource) => { - const blockRepository = dataSource.getRepository(BlockOrmEntity); - const categoryRepository = dataSource.getRepository(CategoryOrmEntity); - - if (await blockRepository.count()) { - return await findBlocks(dataSource); - } - - let defaultCategory = await categoryRepository.findOne({ - where: { label: 'default', builtin: true }, - }); - - if (!defaultCategory) { - defaultCategory = await categoryRepository.save( - categoryRepository.create({ - label: 'default', - builtin: true, - zoom: 100, - offset: [0, 0], - }), - ); - } - - const blockEntities = blockFixtures.map((fixture) => - blockRepository.create({ - ...fixture, - category: defaultCategory ? { id: defaultCategory.id } : null, - } as DeepPartial), - ); - const savedBlocks = await blockRepository.save(blockEntities); - const hasNextBlocks = savedBlocks.find( - (block) => block.name === 'hasNextBlocks', - ); - const hasPreviousBlocks = savedBlocks.find( - (block) => block.name === 'hasPreviousBlocks', - ); - - if (hasNextBlocks && hasPreviousBlocks) { - await blockRepository - .createQueryBuilder() - .relation(BlockOrmEntity, 'nextBlocks') - .of(hasNextBlocks.id) - .add(hasPreviousBlocks.id); - } - - return await findBlocks(dataSource); -}; diff --git a/packages/api/src/utils/test/fixtures/botstats.ts b/packages/api/src/utils/test/fixtures/botstats.ts index 1c8522a35..e375b5e39 100644 --- a/packages/api/src/utils/test/fixtures/botstats.ts +++ b/packages/api/src/utils/test/fixtures/botstats.ts @@ -27,14 +27,14 @@ export const botstatsFixtures: BotStatsCreateDto[] = [ }, { day: new Date('2023-11-03T22:00:00.000Z'), - type: BotStatsType.popular, - name: 'Global Fallback', + type: BotStatsType.returning_users, + name: 'Returning users', value: 34, }, { day: new Date('2023-11-04T23:00:00.000Z'), - type: BotStatsType.new_conversations, - name: 'New conversations', + type: BotStatsType.retention, + name: 'Retentioned users', value: 492, }, { @@ -51,9 +51,9 @@ export const botstatsFixtures: BotStatsCreateDto[] = [ }, { day: new Date('2023-11-03T23:00:00.000Z'), - type: BotStatsType.popular, - name: 'Global Fallback', - value: 34, + type: BotStatsType.echo, + name: 'Echo', + value: 12, }, ]; diff --git a/packages/api/src/utils/test/fixtures/category.ts b/packages/api/src/utils/test/fixtures/category.ts deleted file mode 100644 index 998dc50df..000000000 --- a/packages/api/src/utils/test/fixtures/category.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { DataSource } from 'typeorm'; - -import { Category, CategoryCreateDto } from '@/chat/dto/category.dto'; -import { CategoryOrmEntity } from '@/chat/entities/category.entity'; - -import { getFixturesWithDefaultValues } from '../defaultValues'; -import { FixturesTypeBuilder } from '../types'; - -export type TCategoryFixtures = FixturesTypeBuilder< - Category, - CategoryCreateDto ->; - -export const categoryDefaultValues: TCategoryFixtures['defaultValues'] = { - builtin: false, - zoom: 100, - offset: [0, 0], -}; - -export const categories: TCategoryFixtures['values'][] = [ - { - label: 'test category 1', - }, - { - label: 'test category 2', - }, -]; - -export const categoryFixtures = getFixturesWithDefaultValues< - TCategoryFixtures['values'] ->({ - fixtures: categories, - defaultValues: categoryDefaultValues, -}); - -const findCategories = async (dataSource: DataSource) => - await dataSource - .getRepository(CategoryOrmEntity) - .find({ relations: ['blocks'] }); - -export const installCategoryFixturesTypeOrm = async ( - dataSource: DataSource, -) => { - const repository = dataSource.getRepository(CategoryOrmEntity); - - if (await repository.count()) { - return await findCategories(dataSource); - } - - const entities = categoryFixtures.map((fixture) => - repository.create({ - ...fixture, - builtin: fixture.builtin ?? false, - zoom: fixture.zoom ?? categoryDefaultValues.zoom ?? 100, - offset: fixture.offset ?? categoryDefaultValues.offset ?? [0, 0], - }), - ); - - await repository.save(entities); - - return await findCategories(dataSource); -}; diff --git a/packages/api/src/utils/test/fixtures/conversation.ts b/packages/api/src/utils/test/fixtures/conversation.ts deleted file mode 100644 index 06efc88bf..000000000 --- a/packages/api/src/utils/test/fixtures/conversation.ts +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { DataSource } from 'typeorm'; - -import { ConversationCreateDto } from '@/chat/dto/conversation.dto'; -import { Subscriber } from '@/chat/dto/subscriber.dto'; -import { BlockOrmEntity } from '@/chat/entities/block.entity'; -import { ConversationOrmEntity } from '@/chat/entities/conversation.entity'; -import { SubscriberOrmEntity } from '@/chat/entities/subscriber.entity'; - -import { getFixturesWithDefaultValues } from '../defaultValues'; -import { getLastTypeOrmDataSource } from '../test'; -import { TFixturesDefaultValues } from '../types'; - -import { blockFixtures, installBlockFixturesTypeOrm } from './block'; -import { installSubscriberFixturesTypeOrm } from './subscriber'; - -const makeConversationUser = ( - overrides: Partial & Pick, -): Subscriber => { - const now = new Date(); - - return { - id: overrides.id, - createdAt: overrides.createdAt ?? now, - updatedAt: overrides.updatedAt ?? now, - firstName: overrides.firstName ?? '', - lastName: overrides.lastName ?? '', - locale: overrides.locale ?? null, - timezone: overrides.timezone ?? 0, - language: overrides.language ?? null, - gender: overrides.gender ?? null, - country: overrides.country ?? null, - foreignId: overrides.foreignId ?? '', - assignedAt: overrides.assignedAt ?? null, - lastvisit: overrides.lastvisit ?? now, - retainedFrom: overrides.retainedFrom ?? now, - channel: - overrides.channel ?? - ({ - name: 'unknown-channel', - } as Subscriber['channel']), - context: overrides.context ?? { vars: {} }, - labels: overrides.labels ?? [], - assignedTo: overrides.assignedTo ?? null, - avatar: overrides.avatar ?? null, - }; -}; -const conversations: ConversationCreateDto[] = [ - { - sender: '0', - active: true, - context: { - channel: 'messenger-channel', - text: 'Hi', - payload: '', - nlp: { - entities: [ - { - entity: 'intent', - value: 'greeting', - confidence: 0.999, - }, - ], - }, - vars: { - age: 30, - email: 'email@example.com', - }, - user_location: { - address: { country: 'FR' }, - lat: 35, - lon: 45, - }, - user: makeConversationUser({ - id: '1', - firstName: 'Jhon', - lastName: 'Doe', - language: 'fr', - locale: 'en_EN', - gender: 'male', - country: 'FR', - foreignId: 'foreign-id-messenger', - channel: { name: 'messenger-channel' }, - }), - skip: {}, - attempt: 0, - }, - current: '0', - next: ['1', '2'], - }, - { - sender: '1', - context: { - channel: 'web-channel', - text: 'Hello', - payload: '', - nlp: { - entities: [ - { - entity: 'intent', - value: 'greeting', - confidence: 0.999, - }, - ], - }, - vars: { - age: 30, - email: 'email@example.com', - }, - user_location: { - address: { country: 'US' }, - lat: 15, - lon: 45, - }, - user: makeConversationUser({ - id: '2', - firstName: 'Maynard', - lastName: 'James Keenan', - language: 'en', - locale: 'en_EN', - timezone: 0, - gender: 'male', - country: 'US', - foreignId: 'foreign-id-web-1', - channel: { name: 'web-channel' }, - }), - skip: {}, - attempt: 0, - }, - current: '4', - next: ['3', '4'], - }, -]; - -export const conversationDefaultValues: TFixturesDefaultValues = - { - active: false, - }; - -export const conversationFixtures = - getFixturesWithDefaultValues({ - fixtures: conversations, - defaultValues: conversationDefaultValues, - }); - -const findConversations = async (dataSource: DataSource) => - await dataSource.getRepository(ConversationOrmEntity).find({ - relations: ['sender', 'current', 'next'], - }); - -export const installConversationFixturesTypeOrm = async ( - dataSource: DataSource, -) => { - const repository = dataSource.getRepository(ConversationOrmEntity); - - if (await repository.count()) { - return await findConversations(dataSource); - } - - const [blocks, { subscribers }] = await Promise.all([ - installBlockFixturesTypeOrm(dataSource), - installSubscriberFixturesTypeOrm(dataSource), - ]); - const blocksByName = new Map(blocks.map((block) => [block.name, block])); - const getBlockByIndex = (index: string | undefined) => { - if (index == null) { - return null; - } - - const blockFixture = blockFixtures[Number.parseInt(index, 10)]; - if (!blockFixture) { - return null; - } - - const blockName = blockFixture.name; - if (!blockName) { - return null; - } - - return blocksByName.get(blockName) ?? null; - }; - const conversationsToCreate = conversationFixtures.map((fixture) => { - const { sender, current, next, ...rest } = fixture; - const senderIndex = Number.parseInt(sender, 10); - const senderEntity = subscribers[senderIndex]; - if (!senderEntity) { - throw new Error( - `Missing subscriber fixture at index ${sender} for conversation fixtures`, - ); - } - - const currentEntity = getBlockByIndex(current ?? undefined); - const nextEntities = (next ?? []) - .map((n) => getBlockByIndex(n)) - .filter( - (block): block is Exclude => - block !== null && block !== undefined, - ); - const context = { - ...(rest.context ?? {}), - user: { - id: senderEntity.id, - createdAt: senderEntity.createdAt, - updatedAt: senderEntity.updatedAt, - firstName: senderEntity.firstName, - lastName: senderEntity.lastName, - locale: senderEntity.locale ?? null, - timezone: senderEntity.timezone ?? 0, - language: senderEntity.language ?? null, - gender: senderEntity.gender ?? null, - country: senderEntity.country ?? null, - foreignId: senderEntity.foreignId ?? '', - assignedAt: senderEntity.assignedAt ?? null, - lastvisit: senderEntity.lastvisit ?? null, - retainedFrom: senderEntity.retainedFrom ?? null, - // @todo : remove any - channel: senderEntity.channel as any, - context: senderEntity.context, - }, - }; - - return repository.create({ - ...rest, - context, - sender: { id: senderEntity.id } satisfies Pick, - current: currentEntity - ? ({ id: currentEntity.id } satisfies Pick) - : null, - next: nextEntities.map( - (block) => ({ id: block.id }) satisfies Pick, - ), - }); - }); - - await repository.save(conversationsToCreate); - - return await findConversations(dataSource); -}; - -export const installConversationTypeFixtures = async () => { - const dataSource = getLastTypeOrmDataSource(); - if (!dataSource) { - throw new Error( - 'No TypeORM data source registered for conversation fixtures', - ); - } - - return await installConversationFixturesTypeOrm(dataSource); -}; diff --git a/packages/api/src/utils/test/fixtures/workflow.ts b/packages/api/src/utils/test/fixtures/workflow.ts new file mode 100644 index 000000000..bacb82407 --- /dev/null +++ b/packages/api/src/utils/test/fixtures/workflow.ts @@ -0,0 +1,78 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowDefinition } from '@hexabot-ai/agentic'; +import { DataSource } from 'typeorm'; + +import { QuickReplyType } from '@/chat/types/quick-reply'; +import { WorkflowCreateDto } from '@/workflow/dto/workflow.dto'; +import { WorkflowOrmEntity } from '@/workflow/entities/workflow.entity'; + +/** + * Simple workflow definition that exercises the built-in messaging actions. + * It sends an initial text and follows up with a quick reply prompt. + */ +export const messagingWorkflowDefinition: WorkflowDefinition = { + workflow: { + name: 'messaging_workflow_fixture', + version: '0.1.0', + description: 'Test workflow using messaging actions.', + }, + tasks: { + send_greeting: { + action: 'send_text_message', + inputs: { + text: '="Welcome to Hexabot! Let us know how to help."', + }, + }, + prompt_next_step: { + action: 'send_quick_replies', + description: 'Offer quick replies to continue the conversation.', + inputs: { + text: '="What would you like to do next?"', + quick_replies: [ + { + content_type: QuickReplyType.text, + title: 'Get help', + payload: 'help', + }, + { + content_type: QuickReplyType.text, + title: 'Talk to agent', + payload: 'agent', + }, + ], + }, + }, + }, + flow: [{ do: 'send_greeting' }, { do: 'prompt_next_step' }], + outputs: { + last_prompt: '=$output.prompt_next_step.text ?? ""', + }, +}; + +export const messagingWorkflowFixtures: WorkflowCreateDto[] = [ + { + name: messagingWorkflowDefinition.workflow.name, + version: messagingWorkflowDefinition.workflow.version, + description: messagingWorkflowDefinition.workflow.description ?? undefined, + definition: messagingWorkflowDefinition, + }, +]; + +export const installMessagingWorkflowFixturesTypeOrm = async ( + dataSource: DataSource, +): Promise => { + const repository = dataSource.getRepository(WorkflowOrmEntity); + + if (await repository.count()) { + return await repository.find(); + } + + const entities = repository.create(messagingWorkflowFixtures); + + return await repository.save(entities); +}; diff --git a/packages/api/src/utils/test/mocks/block.ts b/packages/api/src/utils/test/mocks/block.ts deleted file mode 100644 index b6a57c6da..000000000 --- a/packages/api/src/utils/test/mocks/block.ts +++ /dev/null @@ -1,395 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { - customerLabelsMock, - labelMock, -} from '@/channel/lib/__test__/label.mock'; -import { BlockFull } from '@/chat/dto/block.dto'; -import { FileType } from '@/chat/types/attachment'; -import { ButtonType, PayloadType } from '@/chat/types/button'; -import { CaptureVar } from '@/chat/types/capture-var'; -import { OutgoingMessageFormat } from '@/chat/types/message'; -import { BlockOptions, ContentOptions } from '@/chat/types/options'; -import { NlpPattern, Pattern } from '@/chat/types/pattern'; -import { QuickReplyType } from '@/chat/types/quick-reply'; -import { WEB_CHANNEL_NAME } from '@/extensions/channels/web/settings'; - -import { modelInstance } from './misc'; - -const blockOptions: BlockOptions = { - typing: 0, - fallback: { - active: false, - max_attempts: 1, - message: [], - }, -}; -const blockListOptions: BlockOptions = { - content: { - display: OutgoingMessageFormat.list, - fields: { - title: 'title', - subtitle: 'desc', - image_url: 'thumbnail', - }, - buttons: [ - { - type: ButtonType.postback, - title: 'More', - payload: '', - }, - ], - limit: 2, - entity: '1', - }, -}; -const blockCarouselOptions: BlockOptions = { - content: { - display: OutgoingMessageFormat.carousel, - fields: { - title: 'title', - subtitle: 'desc', - image_url: 'thumbnail', - }, - buttons: [ - { - type: ButtonType.postback, - title: 'More', - payload: '', - }, - ], - limit: 3, - entity: '1', - }, -}; -const captureVar: CaptureVar = { - entity: -2, - context_var: 'string', -}; -const position = { - x: 0, - y: 0, -}; - -export const baseBlockInstance: Partial = { - trigger_labels: [labelMock], - assign_labels: [labelMock], - options: blockOptions, - starts_conversation: false, - capture_vars: [captureVar], - position, - builtin: true, - attachedBlock: null, - category: null, - previousBlocks: [], - trigger_channels: [], - nextBlocks: [], - ...modelInstance, -}; - -export const blockEmpty = { - ...baseBlockInstance, - name: 'Empty', - patterns: [], - message: [''], - nextBlocks: [], -} as unknown as BlockFull; - -// Translation Data -export const textResult = ['Hi back !']; - -export const textBlock = { - name: 'message', - patterns: ['Hi'], - message: textResult, - ...baseBlockInstance, -} as unknown as BlockFull; - -export const quickRepliesResult = [ - "What's your favorite color?", - 'Green', - 'Yellow', - 'Red', -]; - -export const quickRepliesBlock = { - name: 'message', - patterns: ['colors'], - message: { - text: "What's your favorite color?", - quickReplies: [ - { - content_type: QuickReplyType.text, - title: 'Green', - payload: 'Green', - }, - { - content_type: QuickReplyType.text, - title: 'Yellow', - payload: 'Yellow', - }, - { - content_type: QuickReplyType.text, - title: 'Red', - payload: 'Red', - }, - ], - }, - ...baseBlockInstance, -} as unknown as BlockFull; - -export const buttonsResult = [ - 'What would you like to know about us?', - 'Vision', - 'Values', - 'Approach', -]; - -export const buttonsBlock = { - name: 'message', - patterns: ['about'], - message: { - text: 'What would you like to know about us?', - buttons: [ - { - type: ButtonType.postback, - title: 'Vision', - payload: 'Vision', - }, - { - type: ButtonType.postback, - title: 'Values', - payload: 'Values', - }, - { - type: ButtonType.postback, - title: 'Approach', - payload: 'Approach', - }, - ], - }, - ...baseBlockInstance, -} as unknown as BlockFull; - -export const attachmentBlock = { - name: 'message', - patterns: ['image'], - message: { - attachment: { - type: FileType.image, - payload: { - url: 'https://fr.facebookbrand.com/wp-content/uploads/2016/09/messenger_icon2.png', - id: '1234', - }, - }, - quickReplies: [], - }, - ...baseBlockInstance, -} as unknown as BlockFull; - -export const allBlocksStringsResult = [ - 'Hi back !', - 'What"s your favorite color?', - 'Green', - 'Yellow', - 'Red', - 'What would you like to know about us?', - 'Vision', - 'Values', - 'Approach', - ':)', - ':D', - ';)', -]; - -///////// - -export const blockGetStarted = { - ...baseBlockInstance, - name: 'Get Started', - patterns: [ - 'Hello', - '/we*lcome/', - { label: 'Get Started', value: 'GET_STARTED' }, - { - label: 'Tounes', - value: 'Tounes', - type: PayloadType.location, - }, - { - label: 'Livre', - value: 'Livre', - type: PayloadType.attachments, - }, - ], - trigger_labels: customerLabelsMock, - message: ['Welcome! How are you ? '], -} as unknown as BlockFull; - -export const mockNlpGreetingPatterns: NlpPattern[] = [ - { - entity: 'intent', - match: 'value', - value: 'greeting', - }, -]; - -export const mockNlpGreetingNamePatterns: NlpPattern[] = [ - { - entity: 'intent', - match: 'value', - value: 'greeting', - }, - { - entity: 'firstname', - match: 'value', - value: 'jhon', - }, -]; - -export const mockNlpGreetingWrongNamePatterns: NlpPattern[] = [ - { - entity: 'intent', - match: 'value', - value: 'greeting', - }, - { - entity: 'firstname', - match: 'value', - value: 'doe', - }, -]; - -export const mockNlpAffirmationPatterns: NlpPattern[] = [ - { - entity: 'intent', - match: 'value', - value: 'affirmation', - }, - { - entity: 'firstname', - match: 'value', - value: 'mark', - }, -]; - -export const mockNlpGreetingAnyNamePatterns: NlpPattern[] = [ - { - entity: 'intent', - match: 'value', - value: 'greeting', - }, - { - entity: 'firstname', - match: 'entity', - }, -]; - -export const mockNlpFirstNamePatterns: NlpPattern[] = [ - { - entity: 'firstname', - match: 'value', - value: 'jhon', - }, -]; - -export const mockModifiedNlpBlock: BlockFull = { - ...baseBlockInstance, - name: 'Modified Mock Nlp', - patterns: [ - 'Hello', - '/we*lcome/', - { label: 'Modified Mock Nlp', value: 'MODIFIED_MOCK_NLP' }, - mockNlpGreetingAnyNamePatterns, - ], - trigger_labels: customerLabelsMock, - message: ['Hello there'], -} as unknown as BlockFull; - -export const mockModifiedNlpBlockOne: BlockFull = { - ...baseBlockInstance, - name: 'Modified Mock Nlp One', - patterns: [ - 'Hello', - '/we*lcome/', - { label: 'Modified Mock Nlp One', value: 'MODIFIED_MOCK_NLP_ONE' }, - mockNlpAffirmationPatterns, - [ - { - entity: 'firstname', - match: 'entity', - }, - ], - ], - trigger_labels: customerLabelsMock, - message: ['Hello Sir'], -} as unknown as BlockFull; - -export const mockModifiedNlpBlockTwo: BlockFull = { - ...baseBlockInstance, - name: 'Modified Mock Nlp Two', - patterns: [ - 'Hello', - '/we*lcome/', - { label: 'Modified Mock Nlp Two', value: 'MODIFIED_MOCK_NLP_TWO' }, - [ - { - entity: 'firstname', - match: 'entity', - }, - ], - mockNlpGreetingAnyNamePatterns, - ], - trigger_labels: customerLabelsMock, - message: ['Hello Madam'], -} as unknown as BlockFull; -const patternsProduct: Pattern[] = [ - 'produit', - [ - { - entity: 'intent', - match: 'value', - value: 'product', - }, - { - entity: 'vetement', - match: 'entity', - }, - ], -]; - -export const blockProductListMock = { - ...baseBlockInstance, - name: 'test_list', - patterns: patternsProduct, - trigger_labels: customerLabelsMock, - assign_labels: [], - options: blockListOptions, - message: { - options: blockListOptions.content as ContentOptions, - elements: [], - pagination: { - total: 0, - skip: 0, - limit: 0, - }, - }, -} as unknown as BlockFull; - -export const blockCarouselMock = { - ...blockProductListMock, - options: blockCarouselOptions, -} as unknown as BlockFull; - -export const blockMocks: BlockFull[] = [blockGetStarted, blockEmpty]; - -export const mockWebChannelData: SubscriberChannelDict[typeof WEB_CHANNEL_NAME] = - { - isSocket: true, - ipAddress: '1.1.1.1', - agent: 'Chromium', - }; diff --git a/packages/api/src/utils/test/mocks/conversation.ts b/packages/api/src/utils/test/mocks/conversation.ts deleted file mode 100644 index 3ecf4b06c..000000000 --- a/packages/api/src/utils/test/mocks/conversation.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Hexabot — Fair Core License (FCL-1.0-ALv2) - * Copyright (c) 2025 Hexastack. - * Full terms: see LICENSE.md. - */ - -import { Block, BlockStub } from '@/chat/dto/block.dto'; -import { ConversationFull } from '@/chat/dto/conversation.dto'; -import { Context } from '@/chat/types/context'; -import { SubscriberContext } from '@/chat/types/subscriberContext'; - -import { quickRepliesBlock, textBlock } from './block'; -import { modelInstance } from './misc'; -import { subscriberInstance } from './subscriber'; - -export const contextBlankInstance: Context = { - channel: 'web-channel', - text: '', - payload: undefined, - nlp: { entities: [] }, - vars: {}, - user_location: { - lat: 0, - lon: 0, - }, - user: subscriberInstance, - skip: {}, - attempt: 1, -}; - -export const subscriberContextBlankInstance: SubscriberContext = { - vars: {}, -}; - -export const contextEmailVarInstance: Context = { - ...contextBlankInstance, - vars: { - email: 'email@example.com', - }, -}; - -export const contextGetStartedInstance: Context = { - channel: 'web-channel', - text: 'Get Started', - payload: 'GET_STARTED', - nlp: { entities: [] }, - vars: { - email: 'email@example.com', - }, - user_location: { - lat: 0, - lon: 0, - }, - user: subscriberInstance, - skip: {}, - attempt: 1, -}; - -export const conversationGetStarted: ConversationFull = { - sender: subscriberInstance, - active: true, - context: contextGetStartedInstance, - current: textBlock as BlockStub as Block, - next: [quickRepliesBlock as any as Block], - ...modelInstance, -}; diff --git a/packages/api/src/utils/test/postman-collections/hexabot_REST.postman_collection.json b/packages/api/src/utils/test/postman-collections/hexabot_REST.postman_collection.json index 2d39bded6..fb6981e98 100644 --- a/packages/api/src/utils/test/postman-collections/hexabot_REST.postman_collection.json +++ b/packages/api/src/utils/test/postman-collections/hexabot_REST.postman_collection.json @@ -41,19 +41,6 @@ }, "response": [] }, - { - "name": "conversation", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/botstats/conversation", - "host": ["{{BASE_URL}}"], - "path": ["botstats", "conversation"] - } - }, - "response": [] - }, { "name": "audience", "request": { @@ -133,233 +120,8 @@ ] }, { - "name": "chat (7) > (34)", + "name": "chat (5) > (23)", "item": [ - { - "name": "category (6)", - "item": [ - { - "name": "CREATE", - "event": [ - { - "listen": "test", - "script": { - "exec": ["pm.setDynamicId();"], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"label\": \"label2\",\n \"builtin\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/category", - "host": ["{{BASE_URL}}"], - "path": ["category"] - } - }, - "response": [] - }, - { - "name": "COUNT", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/category/count", - "host": ["{{BASE_URL}}"], - "path": ["category", "count"] - } - }, - "response": [] - }, - { - "name": "LIST", - "event": [ - { - "listen": "test", - "script": { - "exec": ["pm.setDynamicId();"], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/category", - "host": ["{{BASE_URL}}"], - "path": ["category"] - } - }, - "response": [] - }, - { - "name": "findOneById", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/category/{{CATEGORY_VALID_ID}}", - "host": ["{{BASE_URL}}"], - "path": ["category", "{{CATEGORY_VALID_ID}}"] - } - }, - "response": [] - }, - { - "name": "UPDATE", - "request": { - "method": "PATCH", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"label\": \"label3\",\n \"builtin\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/category/{{CATEGORY_VALID_ID}}", - "host": ["{{BASE_URL}}"], - "path": ["category", "{{CATEGORY_VALID_ID}}"] - } - }, - "response": [] - }, - { - "name": "deleteById", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{BASE_URL}}/category/{{CATEGORY_VALID_ID}}", - "host": ["{{BASE_URL}}"], - "path": ["category", "{{CATEGORY_VALID_ID}}"] - } - }, - "response": [] - } - ] - }, - { - "name": "block (5)", - "item": [ - { - "name": "CREATE", - "event": [ - { - "listen": "test", - "script": { - "exec": ["pm.setDynamicId();"], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"textMessage\",\n \"patterns\": [\n \"Hi\"\n ],\n \"trigger_channels\": [],\n \"message\": [\n \"Hi back !\"\n ],\n \"category\": \"{{CATEGORY_VALID_ID}}\",\n \"position\": {\n \"x\": 0,\n \"y\": 0\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/block", - "host": ["{{BASE_URL}}"], - "path": ["block"] - } - }, - "response": [] - }, - { - "name": "LIST", - "event": [ - { - "listen": "test", - "script": { - "exec": ["pm.setDynamicId();"], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/block", - "host": ["{{BASE_URL}}"], - "path": ["block"] - } - }, - "response": [] - }, - { - "name": "findOneById", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/block/{{BLOCK_VALID_ID}}", - "host": ["{{BASE_URL}}"], - "path": ["block", "{{BLOCK_VALID_ID}}"] - } - }, - "response": [] - }, - { - "name": "UPDATE", - "request": { - "method": "PATCH", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"label\": \"label3\",\n \"builtin\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/block/{{BLOCK_VALID_ID}}", - "host": ["{{BASE_URL}}"], - "path": ["block", "{{BLOCK_VALID_ID}}"] - } - }, - "response": [] - }, - { - "name": "deleteById", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{BASE_URL}}/block/{{BLOCK_VALID_ID}}", - "host": ["{{BASE_URL}}"], - "path": ["block", "{{BLOCK_VALID_ID}}"] - } - }, - "response": [] - } - ] - }, { "name": "context-var (6)", "item": [ diff --git a/packages/api/src/utils/types/extension.ts b/packages/api/src/utils/types/extension.ts index 3aabc677f..30195bfe8 100644 --- a/packages/api/src/utils/types/extension.ts +++ b/packages/api/src/utils/types/extension.ts @@ -6,9 +6,8 @@ import { ChannelName } from '@/channel/types'; import { HelperName } from '@/helper/types'; -import { PluginName } from '@/plugins/types'; -export type ExtensionName = ChannelName | HelperName | PluginName; +export type ExtensionName = ChannelName | HelperName; export type HyphenToUnderscore = S extends `${infer P}-${infer Q}` ? `${P}_${HyphenToUnderscore}` : S; diff --git a/packages/api/src/workflow/controllers/workflow.controller.spec.ts b/packages/api/src/workflow/controllers/workflow.controller.spec.ts new file mode 100644 index 000000000..5a8922cc0 --- /dev/null +++ b/packages/api/src/workflow/controllers/workflow.controller.spec.ts @@ -0,0 +1,205 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { randomUUID } from 'crypto'; + +import { WorkflowDefinition } from '@hexabot-ai/agentic'; +import { NotFoundException } from '@nestjs/common'; +import { TestingModule } from '@nestjs/testing'; + +import { LoggerService } from '@/logger/logger.service'; +import { IGNORED_TEST_FIELDS } from '@/utils/test/constants'; +import { + installMessagingWorkflowFixturesTypeOrm, + messagingWorkflowDefinition, + messagingWorkflowFixtures, +} from '@/utils/test/fixtures/workflow'; +import { closeTypeOrmConnections } from '@/utils/test/test'; +import { buildTestingMocks } from '@/utils/test/utils'; + +import { WorkflowUpdateDto } from '../dto/workflow.dto'; +import { WorkflowService } from '../services/workflow.service'; + +import { WorkflowController } from './workflow.controller'; + +describe('WorkflowController (TypeORM)', () => { + let module: TestingModule; + let workflowController: WorkflowController; + let workflowService: WorkflowService; + let logger: LoggerService; + const createdWorkflowIds = new Set(); + let counter = 0; + + const buildWorkflowPayload = () => { + const definition: WorkflowDefinition = { + workflow: { + name: `workflow_${++counter}`, + version: `1.0.${counter}`, + description: 'Workflow controller test definition', + }, + tasks: { + greet: { action: 'send_text_message', inputs: { text: '="Hi"' } }, + }, + flow: [{ do: 'greet' }], + outputs: { result: '=1' }, + }; + + return { + name: definition.workflow.name, + version: definition.workflow.version, + description: definition.workflow.description, + definition, + }; + }; + + beforeAll(async () => { + const { module: testingModule, getMocks } = await buildTestingMocks({ + autoInjectFrom: ['controllers'], + controllers: [WorkflowController], + typeorm: { + fixtures: installMessagingWorkflowFixturesTypeOrm, + }, + }); + + module = testingModule; + [workflowController, workflowService] = await getMocks([ + WorkflowController, + WorkflowService, + ]); + logger = workflowController.logger; + }); + + afterEach(async () => { + jest.clearAllMocks(); + const ids = Array.from(createdWorkflowIds); + + for (const id of ids) { + await workflowService.deleteOne(id); + createdWorkflowIds.delete(id); + } + }); + + afterAll(async () => { + if (module) { + await module.close(); + } + await closeTypeOrmConnections(); + }); + + describe('findMany', () => { + it('returns workflows matching the provided filters', async () => { + const options = { + where: { name: messagingWorkflowDefinition.workflow.name }, + }; + const findSpy = jest.spyOn(workflowService, 'find'); + const result = await workflowController.findMany(options); + + expect(findSpy).toHaveBeenCalledWith(options); + expect(result).toEqualPayload( + [messagingWorkflowFixtures[0]], + [...IGNORED_TEST_FIELDS], + ); + }); + }); + + describe('create', () => { + it('creates a workflow definition', async () => { + const payload = buildWorkflowPayload(); + const createSpy = jest.spyOn(workflowService, 'create'); + const created = await workflowController.create(payload); + createdWorkflowIds.add(created.id); + + expect(createSpy).toHaveBeenCalledWith(payload); + expect(created).toEqualPayload(payload, [...IGNORED_TEST_FIELDS]); + }); + }); + + describe('findOne', () => { + it('returns a workflow when it exists', async () => { + const [existing] = await workflowService.find({ take: 1 }); + expect(existing).toBeDefined(); + + const findSpy = jest.spyOn(workflowService, 'findOne'); + const result = await workflowController.findOne(existing.id); + + expect(findSpy).toHaveBeenCalledWith(existing.id); + expect(result).toEqualPayload(existing); + }); + + it('throws NotFoundException when workflow is missing', async () => { + const id = randomUUID(); + const warnSpy = jest.spyOn(logger, 'warn'); + + await expect(workflowController.findOne(id)).rejects.toThrow( + new NotFoundException(`Workflow with ID ${id} not found`), + ); + expect(warnSpy).toHaveBeenCalledWith( + `Unable to find Workflow by id ${id}`, + ); + }); + }); + + describe('updateOne', () => { + it('updates an existing workflow', async () => { + const created = await workflowService.create(buildWorkflowPayload()); + createdWorkflowIds.add(created.id); + const updates: WorkflowUpdateDto = { description: 'Updated workflow' }; + const findOneSpy = jest.spyOn(workflowService, 'findOne'); + const updateSpy = jest.spyOn(workflowService, 'updateOne'); + const result = await workflowController.updateOne(created.id, updates); + + expect(findOneSpy).toHaveBeenCalledWith(created.id); + expect(updateSpy).toHaveBeenCalledWith(created.id, updates); + expect(result).toEqualPayload({ ...created, ...updates }, [ + ...IGNORED_TEST_FIELDS, + ]); + }); + + it('throws NotFoundException when attempting to update a missing workflow', async () => { + const id = randomUUID(); + const updateSpy = jest.spyOn(workflowService, 'updateOne'); + const warnSpy = jest.spyOn(logger, 'warn'); + + await expect( + workflowController.updateOne(id, { description: 'Missing workflow' }), + ).rejects.toThrow( + new NotFoundException(`Workflow with ID ${id} not found`), + ); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + `Unable to update Workflow by id ${id}`, + ); + }); + }); + + describe('deleteOne', () => { + it('removes an existing workflow', async () => { + const created = await workflowService.create(buildWorkflowPayload()); + createdWorkflowIds.add(created.id); + const deleteSpy = jest.spyOn(workflowService, 'deleteOne'); + const result = await workflowController.deleteOne(created.id); + + expect(deleteSpy).toHaveBeenCalledWith(created.id); + expect(result).toEqualPayload({ acknowledged: true, deletedCount: 1 }); + expect(await workflowService.findOne(created.id)).toBeNull(); + }); + + it('throws NotFoundException when deletion does not remove anything', async () => { + const id = randomUUID(); + const deleteSpy = jest.spyOn(workflowService, 'deleteOne'); + const warnSpy = jest.spyOn(logger, 'warn'); + + await expect(workflowController.deleteOne(id)).rejects.toThrow( + new NotFoundException(`Workflow with ID ${id} not found`), + ); + expect(deleteSpy).toHaveBeenCalledWith(id); + expect(warnSpy).toHaveBeenCalledWith( + `Unable to delete Workflow by id ${id}`, + ); + }); + }); +}); diff --git a/packages/api/src/workflow/controllers/workflow.controller.ts b/packages/api/src/workflow/controllers/workflow.controller.ts new file mode 100644 index 000000000..984f75c3e --- /dev/null +++ b/packages/api/src/workflow/controllers/workflow.controller.ts @@ -0,0 +1,137 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { + Body, + Controller, + Delete, + Get, + HttpCode, + NotFoundException, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { FindManyOptions } from 'typeorm'; + +import { BaseOrmController } from '@/utils/generics/base-orm.controller'; +import { DeleteResult } from '@/utils/generics/base-orm.repository'; +import { TypeOrmSearchFilterPipe } from '@/utils/pipes/typeorm-search-filter.pipe'; + +import { + Workflow, + WorkflowCreateDto, + WorkflowDtoConfig, + WorkflowTransformerDto, + WorkflowUpdateDto, +} from '../dto/workflow.dto'; +import { WorkflowOrmEntity } from '../entities/workflow.entity'; +import { WorkflowService } from '../services/workflow.service'; + +@Controller('workflow') +export class WorkflowController extends BaseOrmController< + WorkflowOrmEntity, + WorkflowTransformerDto, + WorkflowDtoConfig +> { + constructor(private readonly workflowService: WorkflowService) { + super(workflowService); + } + + /** + * Creates a new workflow definition. + * + * @param workflowCreateDto - Workflow properties and definition to persist. + * + * @returns The newly created workflow. + */ + @Post() + async create( + @Body() workflowCreateDto: WorkflowCreateDto, + ): Promise { + return await this.workflowService.create(workflowCreateDto); + } + + /** + * Retrieves workflows matching the provided filters. + * + * @param options - Combined filters, pagination, and sorting for the query. + * + * @returns Workflows that satisfy the provided options. + */ + @Get() + async findMany( + @Query( + new TypeOrmSearchFilterPipe({ + allowedFields: ['name', 'version', 'description'], + defaultSort: ['createdAt', 'desc'], + }), + ) + options: FindManyOptions = {}, + ): Promise { + return await this.workflowService.find(options ?? {}); + } + + /** + * Finds a single workflow by its identifier. + * + * @param id - The workflow ID. + * + * @returns The workflow matching the provided ID. + */ + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const workflow = await this.workflowService.findOne(id); + if (!workflow) { + this.logger.warn(`Unable to find Workflow by id ${id}`); + throw new NotFoundException(`Workflow with ID ${id} not found`); + } + + return workflow; + } + + /** + * Updates an existing workflow definition. + * + * @param id - The workflow ID to update. + * @param workflowUpdateDto - Partial workflow attributes to apply. + * + * @returns The updated workflow definition. + */ + @Patch(':id') + async updateOne( + @Param('id') id: string, + @Body() workflowUpdateDto: WorkflowUpdateDto, + ): Promise { + const workflow = await this.workflowService.findOne(id); + if (!workflow) { + this.logger.warn(`Unable to update Workflow by id ${id}`); + throw new NotFoundException(`Workflow with ID ${id} not found`); + } + + return await this.workflowService.updateOne(id, workflowUpdateDto); + } + + /** + * Deletes a workflow definition. + * + * @param id - The workflow ID to delete. + * + * @returns Deletion result indicating how many records were removed. + */ + @Delete(':id') + @HttpCode(204) + async deleteOne(@Param('id') id: string): Promise { + const result = await this.workflowService.deleteOne(id); + if (result.deletedCount === 0) { + this.logger.warn(`Unable to delete Workflow by id ${id}`); + throw new NotFoundException(`Workflow with ID ${id} not found`); + } + + return result; + } +} diff --git a/packages/api/src/workflow/defaults/context.ts b/packages/api/src/workflow/defaults/context.ts new file mode 100644 index 000000000..ef827b358 --- /dev/null +++ b/packages/api/src/workflow/defaults/context.ts @@ -0,0 +1,34 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { Subscriber } from '@/chat/dto/subscriber.dto'; +import { Context } from '@/chat/types/context'; + +/** + * Default chat context used to bootstrap a workflow run before any + * user-provided data is captured. + */ +export function getDefaultWorkflowContext(): Context { + return { + vars: {}, + channel: null, + text: null, + payload: null, + nlp: null, + user: { + firstName: '', + lastName: '', + } as Subscriber, + user_location: { + lat: 0.0, + lon: 0.0, + }, + skip: {}, + attempt: 0, + }; +} + +export default getDefaultWorkflowContext; diff --git a/packages/api/src/workflow/defaults/default-workflow.ts b/packages/api/src/workflow/defaults/default-workflow.ts new file mode 100644 index 000000000..cb813c89e --- /dev/null +++ b/packages/api/src/workflow/defaults/default-workflow.ts @@ -0,0 +1,37 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowDefinition } from '@hexabot-ai/agentic'; + +/** + * Minimal fallback workflow used when no custom workflows are configured. + * It leverages the messaging actions to acknowledge the subscriber and suspend + * until the next incoming message resumes the run. + */ +export const defaultWorkflowDefinition: WorkflowDefinition = { + workflow: { + name: 'default_messaging_workflow', + version: '0.1.0', + description: + 'Fallback workflow that echoes the subscriber message using messaging actions.', + }, + tasks: { + send_default_reply: { + action: 'send_text_message', + description: 'Send a basic acknowledgment using messaging actions.', + inputs: { + text: '="Thanks for reaching out! You said: " & ($input.message.text ? $input.message.text : "[no text]")', + }, + }, + }, + flow: [{ do: 'send_default_reply' }], + outputs: { + last_message_text: + '=$exists($output.send_default_reply.text) ? $output.send_default_reply.text : ""', + }, +}; + +export default defaultWorkflowDefinition; diff --git a/packages/api/src/workflow/dto/workflow-run.dto.ts b/packages/api/src/workflow/dto/workflow-run.dto.ts new file mode 100644 index 000000000..a99990bd2 --- /dev/null +++ b/packages/api/src/workflow/dto/workflow-run.dto.ts @@ -0,0 +1,223 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowRunStatus, WorkflowSnapshot } from '@hexabot-ai/agentic'; +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { Exclude, Expose, Transform, Type } from 'class-transformer'; +import { + IsDate, + IsIn, + IsNotEmpty, + IsObject, + IsOptional, + IsString, +} from 'class-validator'; + +import { Subscriber } from '@/chat/dto/subscriber.dto'; +import { IsUUIDv4 } from '@/utils/decorators/is-uuid.decorator'; +import { + BaseStub, + DtoActionConfig, + DtoTransformerConfig, +} from '@/utils/types/dto.types'; + +import { WORKFLOW_RUN_STATUSES } from '../entities/workflow-run.entity'; + +import { Workflow } from './workflow.dto'; + +@Exclude() +export class WorkflowRunStub extends BaseStub { + @Expose() + status!: WorkflowRunStatus; + + @Expose() + input?: Record | null; + + @Expose() + output?: Record | null; + + @Expose() + memory?: Record | null; + + @Expose() + context?: Record | null; + + @Expose() + snapshot?: WorkflowSnapshot | null; + + @Expose() + suspendedStep?: string | null; + + @Expose() + suspensionReason?: string | null; + + @Expose() + suspensionData?: unknown; + + @Expose() + lastResumeData?: unknown; + + @Expose() + error?: string | null; + + @Expose() + suspendedAt?: Date | null; + + @Expose() + finishedAt?: Date | null; + + @Expose() + failedAt?: Date | null; + + @Expose() + correlationId?: string | null; + + @Expose() + metadata?: Record | null; +} + +@Exclude() +export class WorkflowRun extends WorkflowRunStub { + @Expose({ name: 'workflowId' }) + workflow!: string; + + @Expose({ name: 'subscriberId' }) + @Transform(({ value }) => (value == null ? undefined : value)) + subscriber?: string | null; +} + +@Exclude() +export class WorkflowRunFull extends WorkflowRunStub { + @Expose() + @Type(() => Workflow) + workflow!: Workflow; + + @Expose() + @Type(() => Subscriber) + subscriber?: Subscriber | null; +} + +export class WorkflowRunCreateDto { + @ApiProperty({ description: 'Workflow to execute', type: String }) + @IsNotEmpty() + @IsUUIDv4({ + message: 'Workflow must be a valid UUID', + }) + workflow!: string; + + @ApiPropertyOptional({ + description: 'Subscriber linked to the run', + type: String, + }) + @IsOptional() + @IsUUIDv4({ + message: 'Subscriber must be a valid UUID', + }) + subscriber?: string | null; + + @ApiPropertyOptional({ + description: 'Lifecycle status of the run', + enum: WORKFLOW_RUN_STATUSES, + }) + @IsOptional() + @IsIn(WORKFLOW_RUN_STATUSES) + status?: WorkflowRunStatus; + + @ApiPropertyOptional({ description: 'Input payload', type: Object }) + @IsOptional() + @IsObject() + input?: Record | null; + + @ApiPropertyOptional({ description: 'Current memory state', type: Object }) + @IsOptional() + @IsObject() + memory?: Record | null; + + @ApiPropertyOptional({ description: 'Context snapshot', type: Object }) + @IsOptional() + @IsObject() + context?: Record | null; + + @ApiPropertyOptional({ description: 'Workflow output', type: Object }) + @IsOptional() + @IsObject() + output?: Record | null; + + @ApiPropertyOptional({ description: 'Runner snapshot', type: Object }) + @IsOptional() + @IsObject() + snapshot?: WorkflowSnapshot | null; + + @ApiPropertyOptional({ + description: 'Step id where the run is suspended', + type: String, + }) + @IsOptional() + @IsString() + suspendedStep?: string | null; + + @ApiPropertyOptional({ description: 'Suspension reason', type: String }) + @IsOptional() + @IsString() + suspensionReason?: string | null; + + @ApiPropertyOptional({ description: 'Suspension payload', type: Object }) + @IsOptional() + suspensionData?: unknown; + + @ApiPropertyOptional({ description: 'Last resume payload', type: Object }) + @IsOptional() + lastResumeData?: unknown; + + @ApiPropertyOptional({ description: 'Error message if failed', type: String }) + @IsOptional() + @IsString() + error?: string | null; + + @ApiPropertyOptional({ description: 'Suspension timestamp', type: Date }) + @IsOptional() + @IsDate() + @Type(() => Date) + suspendedAt?: Date | null; + + @ApiPropertyOptional({ description: 'Completion timestamp', type: Date }) + @IsOptional() + @IsDate() + @Type(() => Date) + finishedAt?: Date | null; + + @ApiPropertyOptional({ description: 'Failure timestamp', type: Date }) + @IsOptional() + @IsDate() + @Type(() => Date) + failedAt?: Date | null; + + @ApiPropertyOptional({ + description: 'External identifier used to correlate events', + type: String, + }) + @IsOptional() + @IsString() + correlationId?: string | null; + + @ApiPropertyOptional({ description: 'Opaque metadata', type: Object }) + @IsOptional() + metadata?: Record | null; +} + +export type WorkflowRunTransformerDto = DtoTransformerConfig<{ + PlainCls: typeof WorkflowRun; + FullCls: typeof WorkflowRunFull; +}>; + +export class WorkflowRunUpdateDto extends PartialType(WorkflowRunCreateDto) {} + +export type WorkflowRunDtoConfig = DtoActionConfig<{ + create: WorkflowRunCreateDto; + update: WorkflowRunUpdateDto; +}>; + +export type WorkflowRunDto = WorkflowRunDtoConfig; diff --git a/packages/api/src/workflow/dto/workflow.dto.ts b/packages/api/src/workflow/dto/workflow.dto.ts new file mode 100644 index 000000000..8f29e1324 --- /dev/null +++ b/packages/api/src/workflow/dto/workflow.dto.ts @@ -0,0 +1,73 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowDefinition } from '@hexabot-ai/agentic'; +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { Exclude, Expose } from 'class-transformer'; +import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; + +import { + BaseStub, + DtoActionConfig, + DtoTransformerConfig, +} from '@/utils/types/dto.types'; + +@Exclude() +export class WorkflowStub extends BaseStub { + @Expose() + name!: string; + + @Expose() + version!: string; + + @Expose() + description?: string | null; + + @Expose() + definition!: WorkflowDefinition; +} + +@Exclude() +export class Workflow extends WorkflowStub {} + +@Exclude() +export class WorkflowFull extends WorkflowStub {} + +export class WorkflowCreateDto { + @ApiProperty({ description: 'Workflow name', type: String }) + @IsNotEmpty() + @IsString() + name!: string; + + @ApiProperty({ description: 'Workflow version', type: String }) + @IsNotEmpty() + @IsString() + version!: string; + + @ApiPropertyOptional({ description: 'Workflow description', type: String }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ description: 'Workflow definition', type: Object }) + @IsNotEmpty() + @IsObject() + definition!: WorkflowDefinition; +} + +export type WorkflowTransformerDto = DtoTransformerConfig<{ + PlainCls: typeof Workflow; + FullCls: typeof WorkflowFull; +}>; + +export class WorkflowUpdateDto extends PartialType(WorkflowCreateDto) {} + +export type WorkflowDtoConfig = DtoActionConfig<{ + create: WorkflowCreateDto; + update: WorkflowUpdateDto; +}>; + +export type WorkflowDto = WorkflowDtoConfig; diff --git a/packages/api/src/workflow/entities/workflow-run.entity.ts b/packages/api/src/workflow/entities/workflow-run.entity.ts new file mode 100644 index 000000000..5f483227b --- /dev/null +++ b/packages/api/src/workflow/entities/workflow-run.entity.ts @@ -0,0 +1,133 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowRunStatus, WorkflowSnapshot } from '@hexabot-ai/agentic'; +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from 'typeorm'; + +import { SubscriberOrmEntity } from '@/chat/entities/subscriber.entity'; +import { DatetimeColumn } from '@/database/decorators/datetime-column.decorator'; +import { EnumColumn } from '@/database/decorators/enum-column.decorator'; +import { JsonColumn } from '@/database/decorators/json-column.decorator'; +import { BaseOrmEntity } from '@/database/entities/base.entity'; +import { AsRelation } from '@/utils/decorators/relation-ref.decorator'; + +import { WorkflowOrmEntity } from './workflow.entity'; + +export const WORKFLOW_RUN_STATUSES: WorkflowRunStatus[] = [ + 'idle', + 'running', + 'suspended', + 'finished', + 'failed', +]; + +@Entity({ name: 'workflow_runs' }) +export class WorkflowRunOrmEntity extends BaseOrmEntity { + /** Workflow definition executed by this run. */ + @ManyToOne(() => WorkflowOrmEntity, { + nullable: false, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workflow_id' }) + @AsRelation() + workflow!: WorkflowOrmEntity; + + /** Identifier of the linked workflow (for internal relations). */ + @RelationId((run: WorkflowRunOrmEntity) => run.workflow) + private readonly workflowId!: string; + + /** Subscriber linked to the run, if applicable. */ + @ManyToOne(() => SubscriberOrmEntity, { + nullable: true, + onDelete: 'SET NULL', + }) + @JoinColumn({ name: 'subscriber_id' }) + @AsRelation() + subscriber?: SubscriberOrmEntity | null; + + /** Identifier of the linked subscriber (for internal relations). */ + @RelationId((run: WorkflowRunOrmEntity) => run.subscriber) + private readonly subscriberId?: string | null; + + /** Lifecycle status of the run (idle, running, suspended, finished, failed). */ + @EnumColumn({ enum: WORKFLOW_RUN_STATUSES, default: 'idle' }) + status!: WorkflowRunStatus; + + /** Input payload provided at run start. */ + @JsonColumn({ nullable: true }) + input?: Record | null; + + /** Output payload produced by the workflow. */ + @JsonColumn({ nullable: true }) + output?: Record | null; + + /** Working memory accumulated during execution. */ + @JsonColumn({ nullable: true }) + memory?: Record | null; + + /** Context object shared across workflow steps. */ + @JsonColumn({ nullable: true }) + context?: Record | null; + + /** Engine snapshot capturing the runner state. */ + @JsonColumn({ nullable: true }) + snapshot?: WorkflowSnapshot | null; + + /** Step identifier where the run was suspended. */ + @Column({ + name: 'suspended_step', + type: 'varchar', + length: 255, + nullable: true, + }) + suspendedStep?: string | null; + + /** Free-text reason explaining why the run was suspended. */ + @Column({ + name: 'suspension_reason', + type: 'varchar', + length: 255, + nullable: true, + }) + suspensionReason?: string | null; + + /** Payload captured when the run was suspended. */ + @JsonColumn({ name: 'suspension_data', nullable: true }) + suspensionData?: unknown; + + /** Payload stored to resume the run after suspension. */ + @JsonColumn({ name: 'last_resume_data', nullable: true }) + lastResumeData?: unknown; + + /** Error message when the run fails. */ + @Column({ name: 'error', type: 'text', nullable: true }) + error?: string | null; + + /** Timestamp when the run entered the suspended state. */ + @DatetimeColumn({ name: 'suspended_at', nullable: true }) + suspendedAt?: Date | null; + + /** Timestamp when the run successfully finished. */ + @DatetimeColumn({ name: 'finished_at', nullable: true }) + finishedAt?: Date | null; + + /** Timestamp when the run failed irrecoverably. */ + @DatetimeColumn({ name: 'failed_at', nullable: true }) + failedAt?: Date | null; + + /** External correlation identifier used to link events. */ + @Column({ + name: 'correlation_id', + type: 'varchar', + length: 255, + nullable: true, + }) + correlationId?: string | null; + + /** Additional opaque metadata associated with the run. */ + @JsonColumn({ nullable: true }) + metadata?: Record | null; +} diff --git a/packages/api/src/workflow/entities/workflow.entity.ts b/packages/api/src/workflow/entities/workflow.entity.ts new file mode 100644 index 000000000..d772ad6cf --- /dev/null +++ b/packages/api/src/workflow/entities/workflow.entity.ts @@ -0,0 +1,31 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowDefinition } from '@hexabot-ai/agentic'; +import { Column, Entity, Index } from 'typeorm'; + +import { JsonColumn } from '@/database/decorators/json-column.decorator'; +import { BaseOrmEntity } from '@/database/entities/base.entity'; + +@Entity({ name: 'workflows' }) +@Index(['name', 'version'], { unique: true }) +export class WorkflowOrmEntity extends BaseOrmEntity { + /** Human-readable workflow name, unique per version. */ + @Column({ type: 'varchar', length: 255 }) + name!: string; + + /** Version label for the workflow definition (e.g. semver or tag). */ + @Column({ type: 'varchar', length: 50 }) + version!: string; + + /** Optional description to explain the workflow's purpose. */ + @Column({ type: 'text', nullable: true }) + description?: string | null; + + /** Structured workflow definition consumed by the agent runtime. */ + @JsonColumn() + definition!: WorkflowDefinition; +} diff --git a/packages/api/src/workflow/index.ts b/packages/api/src/workflow/index.ts new file mode 100644 index 000000000..c0e723ccc --- /dev/null +++ b/packages/api/src/workflow/index.ts @@ -0,0 +1,33 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +export * from './workflow.module'; + +export * from './controllers/workflow.controller'; + +export * from './dto/workflow.dto'; + +export * from './dto/workflow-run.dto'; + +export * from './entities/workflow.entity'; + +export * from './entities/workflow-run.entity'; + +export * from './repositories/workflow.repository'; + +export * from './repositories/workflow-run.repository'; + +export * from './services/workflow-context'; + +export * from './services/workflow.service'; + +export * from './services/workflow-run.service'; + +export * from './services/agentic.service'; + +export * from './defaults/default-workflow'; + +export * from './defaults/context'; diff --git a/packages/api/src/workflow/repositories/workflow-run.repository.ts b/packages/api/src/workflow/repositories/workflow-run.repository.ts new file mode 100644 index 000000000..8b0241001 --- /dev/null +++ b/packages/api/src/workflow/repositories/workflow-run.repository.ts @@ -0,0 +1,41 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { BaseOrmRepository } from '@/utils/generics/base-orm.repository'; + +import { + WorkflowRun, + WorkflowRunDtoConfig, + WorkflowRunFull, + WorkflowRunTransformerDto, +} from '../dto/workflow-run.dto'; +import { WorkflowRunOrmEntity } from '../entities/workflow-run.entity'; + +@Injectable() +export class WorkflowRunRepository extends BaseOrmRepository< + WorkflowRunOrmEntity, + WorkflowRunTransformerDto, + WorkflowRunDtoConfig +> { + /** + * Creates the repository with the TypeORM backing repository. + * + * @param repository - TypeORM repository bound to the workflow run entity. + */ + constructor( + @InjectRepository(WorkflowRunOrmEntity) + repository: Repository, + ) { + super(repository, ['workflow', 'subscriber'], { + PlainCls: WorkflowRun, + FullCls: WorkflowRunFull, + }); + } +} diff --git a/packages/api/src/workflow/repositories/workflow.repository.ts b/packages/api/src/workflow/repositories/workflow.repository.ts new file mode 100644 index 000000000..aa9fd9cd4 --- /dev/null +++ b/packages/api/src/workflow/repositories/workflow.repository.ts @@ -0,0 +1,38 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { BaseOrmRepository } from '@/utils/generics/base-orm.repository'; + +import { + Workflow, + WorkflowDtoConfig, + WorkflowFull, + WorkflowTransformerDto, +} from '../dto/workflow.dto'; +import { WorkflowOrmEntity } from '../entities/workflow.entity'; + +@Injectable() +export class WorkflowRepository extends BaseOrmRepository< + WorkflowOrmEntity, + WorkflowTransformerDto, + WorkflowDtoConfig +> { + /** + * Creates the repository with the underlying TypeORM repository. + * + * @param repository - TypeORM repository bound to the workflow entity. + */ + constructor( + @InjectRepository(WorkflowOrmEntity) + repository: Repository, + ) { + super(repository, [], { PlainCls: Workflow, FullCls: WorkflowFull }); + } +} diff --git a/packages/api/src/workflow/services/agentic.service.spec.ts b/packages/api/src/workflow/services/agentic.service.spec.ts new file mode 100644 index 000000000..f0b03117e --- /dev/null +++ b/packages/api/src/workflow/services/agentic.service.spec.ts @@ -0,0 +1,464 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { Workflow as AgentWorkflow } from '@hexabot-ai/agentic'; +import { TestingModule } from '@nestjs/testing'; + +import { ActionService } from '@/actions/actions.service'; +import EventWrapper from '@/channel/lib/EventWrapper'; +import { Subscriber } from '@/chat/dto/subscriber.dto'; +import { LoggerService } from '@/logger/logger.service'; +import { messagingWorkflowDefinition } from '@/utils/test/fixtures/workflow'; +import { closeTypeOrmConnections } from '@/utils/test/test'; +import { buildTestingMocks } from '@/utils/test/utils'; + +import { WorkflowRunFull } from '../dto/workflow-run.dto'; +import { Workflow } from '../dto/workflow.dto'; + +import { AgenticService } from './agentic.service'; +import { WorkflowContext } from './workflow-context'; +import { WorkflowRunService } from './workflow-run.service'; +import { WorkflowService } from './workflow.service'; + +jest.mock('@hexabot-ai/agentic', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { EventEmitter } = require('events'); + + class MockWorkflow { + static fromDefinition = jest.fn(); + + buildRunnerFromState = jest.fn(); + + buildAsyncRunner = jest.fn(); + } + + class MockWorkflowEventEmitter extends EventEmitter {} + + class MockBaseWorkflowContext { + state: Record; + + workflow: any; + + constructor(initial?: Record) { + this.state = initial ?? {}; + } + + attachWorkflowRuntime(runtime: any) { + this.workflow = runtime; + } + } + + return { + Workflow: MockWorkflow, + WorkflowEventEmitter: MockWorkflowEventEmitter, + BaseWorkflowContext: MockBaseWorkflowContext, + }; +}); + +type EventOverrides = Partial<{ + channelData: Record; + messageType: unknown; + eventType: unknown; + payload: unknown; + message: unknown; + text: string; + id: string | undefined; +}>; + +const buildEvent = ( + subscriber?: Subscriber, + overrides: EventOverrides = {}, +): EventWrapper => { + const message = overrides.message ?? { text: 'Hello from user' }; + const handler = { + getName: jest.fn(() => 'web'), + sendMessage: jest.fn().mockResolvedValue({ mid: 'outgoing-mid' }), + }; + + return { + getSender: jest.fn(() => subscriber), + getChannelData: jest.fn(() => overrides.channelData ?? { name: 'web' }), + getMessageType: jest.fn(() => overrides.messageType ?? 'text'), + getEventType: jest.fn(() => overrides.eventType ?? 'message'), + getPayload: jest.fn(() => overrides.payload ?? { payload: 'foo' }), + getMessage: jest.fn(() => message), + getText: jest.fn(() => overrides.text ?? (message as any).text ?? ''), + getId: jest.fn(() => overrides.id ?? 'mid-123'), + getHandler: jest.fn(() => handler), + } as unknown as EventWrapper; +}; + +describe('AgenticService', () => { + let testingModule: TestingModule; + let service: AgenticService; + let workflowService: jest.Mocked; + let workflowRunService: jest.Mocked; + let actionService: jest.Mocked; + let workflowContext: jest.Mocked; + let logger: jest.Mocked; + + const mockActions = [ + { getName: () => 'send_text_message' }, + { getName: () => 'send_quick_replies' }, + ] as any[]; + + beforeAll(async () => { + workflowService = { + pickWorkflow: jest.fn(), + } as unknown as jest.Mocked; + workflowRunService = { + findOneAndPopulate: jest.fn(), + findSuspendedRunBySubscriber: jest.fn(), + create: jest.fn(), + markRunning: jest.fn(), + markSuspended: jest.fn(), + markFinished: jest.fn(), + markFailed: jest.fn(), + updateOne: jest.fn(), + } as unknown as jest.Mocked; + actionService = { + getAll: jest.fn(() => mockActions), + } as unknown as jest.Mocked; + workflowContext = new WorkflowContext({}) as jest.Mocked; + logger = { + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + } as unknown as jest.Mocked; + + const testing = await buildTestingMocks({ + providers: [ + AgenticService, + { provide: WorkflowService, useValue: workflowService }, + { provide: WorkflowRunService, useValue: workflowRunService }, + { provide: ActionService, useValue: actionService }, + { provide: WorkflowContext, useValue: workflowContext }, + { provide: LoggerService, useValue: logger }, + ], + }); + + testingModule = testing.module; + [service] = await testing.getMocks([AgenticService]); + }); + + afterEach(() => { + jest.clearAllMocks(); + workflowContext.state = {}; + }); + + afterAll(async () => { + await testingModule?.close(); + await closeTypeOrmConnections(); + }); + + it('logs a warning and skips when no subscriber is present', async () => { + const event = buildEvent(undefined); + + await service.handleMessageEvent(event); + + expect(logger.warn).toHaveBeenCalledWith( + 'Skipping workflow execution due to missing subscriber on event', + ); + expect( + workflowRunService.findSuspendedRunBySubscriber, + ).not.toHaveBeenCalled(); + }); + + it('resumes a suspended run and persists suspension state', async () => { + const subscriber = { id: 'subscriber-1' } as Subscriber; + const resumeMessage = { text: 'Resume message' }; + const event = buildEvent(subscriber, { message: resumeMessage }); + const workflow: Workflow = { + id: 'workflow-1', + name: messagingWorkflowDefinition.workflow.name, + version: messagingWorkflowDefinition.workflow.version, + definition: messagingWorkflowDefinition, + description: messagingWorkflowDefinition.workflow.description, + createdAt: new Date(), + updatedAt: new Date(), + } as Workflow; + const run: WorkflowRunFull = { + id: 'run-1', + status: 'suspended', + workflow, + subscriber, + input: { foo: 'bar' }, + output: { prev: true }, + memory: { cache: true }, + context: { stored: true }, + snapshot: { status: 'suspended', actions: {} }, + metadata: { + state: { + iteration: 1, + accumulator: { count: 1 }, + iterationStack: ['initial'], + }, + note: 'keep', + }, + suspendedStep: 'prompt_next_step', + suspensionReason: 'awaiting_input', + suspensionData: { previous: true }, + lastResumeData: { prev: 'data' }, + createdAt: new Date(), + updatedAt: new Date(), + } as WorkflowRunFull; + const runnerState = { + input: { fromState: true }, + output: { collected: true }, + memory: { resumed: true }, + iteration: 3, + accumulator: { loops: 2 }, + iterationStack: ['flow'], + }; + const runner = { + resume: jest.fn().mockResolvedValue({ + status: 'suspended', + step: { id: 'prompt_next_step' }, + reason: 'awaiting_user', + data: { resume: true }, + snapshot: { status: 'suspended', actions: {} }, + }), + getSnapshot: jest + .fn() + .mockReturnValue({ status: 'snapshot', actions: {} }), + state: runnerState, + }; + const workflowInstance = { + buildRunnerFromState: jest.fn().mockResolvedValue(runner), + buildAsyncRunner: jest.fn(), + }; + (AgentWorkflow as any).fromDefinition.mockReturnValue(workflowInstance); + workflowRunService.findSuspendedRunBySubscriber.mockResolvedValue(run); + workflowRunService.markRunning.mockResolvedValue(run as any); + workflowRunService.markSuspended.mockResolvedValue({ + ...run, + status: 'suspended', + } as any); + workflowRunService.updateOne.mockResolvedValue({ + ...run, + status: 'suspended', + } as any); + workflowContext.state = { persisted: 'context' }; + + await service.handleMessageEvent(event); + + const expectedContext = { + stored: true, + persisted: 'context', + subscriberId: subscriber.id, + workflowId: workflow.id, + runId: run.id, + }; + + expect(workflowInstance.buildRunnerFromState).toHaveBeenCalledWith({ + state: { + input: run.input, + memory: run.memory, + output: run.output, + iterationStack: ['initial'], + iteration: 1, + accumulator: { count: 1 }, + }, + context: workflowContext, + snapshot: run.snapshot, + suspension: { + stepId: run.suspendedStep, + reason: run.suspensionReason, + data: run.suspensionData, + }, + runId: run.id, + lastResumeData: run.lastResumeData, + }); + expect(workflowRunService.markRunning).toHaveBeenCalledWith(run.id, { + lastResumeData: resumeMessage, + snapshot: run.snapshot, + memory: run.memory, + context: expectedContext, + }); + expect(runner.resume).toHaveBeenCalledWith({ resumeData: resumeMessage }); + expect(workflowRunService.markSuspended).toHaveBeenCalledWith(run.id, { + stepId: 'prompt_next_step', + reason: 'awaiting_user', + data: { resume: true }, + snapshot: { status: 'suspended', actions: {} }, + memory: runnerState.memory, + context: expectedContext, + lastResumeData: resumeMessage, + }); + expect(workflowRunService.updateOne).toHaveBeenCalledWith(run.id, { + input: runnerState.input, + output: runnerState.output, + memory: runnerState.memory, + metadata: { + note: 'keep', + state: { + iteration: runnerState.iteration, + accumulator: runnerState.accumulator, + iterationStack: runnerState.iterationStack, + }, + }, + context: expectedContext, + }); + }); + + it('starts a new run when no suspension exists', async () => { + const subscriber = { id: 'subscriber-2' } as Subscriber; + const event = buildEvent(subscriber, { + channelData: { name: 'web', channel: 'test' }, + message: { text: 'Hello there' }, + id: 'evt-1', + }); + const expectedInput = { + channel: { name: 'web', channel: 'test' }, + message_type: 'text', + event_type: 'message', + sender: subscriber, + payload: { payload: 'foo' }, + message: { text: 'Hello there' }, + text: 'Hello there', + mid: 'evt-1', + }; + const workflow: Workflow = { + id: 'workflow-2', + name: messagingWorkflowDefinition.workflow.name, + version: messagingWorkflowDefinition.workflow.version, + definition: { + ...messagingWorkflowDefinition, + memory: { seen: false }, + context: { greeting: true }, + }, + description: messagingWorkflowDefinition.workflow.description, + createdAt: new Date(), + updatedAt: new Date(), + } as Workflow; + const createdRun = { id: 'run-2' } as any; + const populatedRun: WorkflowRunFull = { + id: createdRun.id, + status: 'idle', + workflow, + subscriber, + input: expectedInput, + output: null, + memory: null, + context: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + } as WorkflowRunFull; + const runnerState = { + input: { hydrated: true }, + output: { fromState: true }, + memory: { started: true }, + iterationStack: [], + iteration: 0, + accumulator: undefined, + }; + const runner = { + start: jest.fn().mockResolvedValue({ + status: 'finished', + snapshot: { status: 'finished', actions: {} }, + output: { result: 'done' }, + }), + getSnapshot: jest + .fn() + .mockReturnValue({ status: 'finished', actions: {} }), + state: runnerState, + }; + const workflowInstance = { + buildRunnerFromState: jest.fn(), + buildAsyncRunner: jest.fn().mockResolvedValue(runner), + }; + (AgentWorkflow as any).fromDefinition.mockReturnValue(workflowInstance); + workflowRunService.findSuspendedRunBySubscriber.mockResolvedValue(null); + workflowService.pickWorkflow.mockResolvedValue(workflow); + workflowRunService.findOneAndPopulate.mockResolvedValue(populatedRun); + workflowRunService.create.mockResolvedValue(createdRun); + workflowRunService.markRunning.mockResolvedValue({ + ...populatedRun, + status: 'running', + } as any); + workflowRunService.markFinished.mockResolvedValue({ + ...populatedRun, + status: 'finished', + } as any); + workflowRunService.updateOne.mockResolvedValue({ + ...populatedRun, + status: 'finished', + } as any); + workflowContext.state = { existing: 'context' }; + + await service.handleMessageEvent(event); + + const expectedContext = { + existing: 'context', + subscriberId: subscriber.id, + workflowId: workflow.id, + runId: populatedRun.id, + }; + expect(workflowRunService.create).toHaveBeenCalledWith({ + workflow: workflow.id, + subscriber: subscriber.id, + input: expectedInput, + memory: workflow.definition.memory, + context: workflow.definition.context, + metadata: { channel: { name: 'web', channel: 'test' } }, + }); + expect(workflowInstance.buildAsyncRunner).toHaveBeenCalledWith({ + runId: populatedRun.id, + }); + expect(workflowRunService.markRunning).toHaveBeenCalledWith( + populatedRun.id, + { + snapshot: null, + memory: workflow.definition.memory, + context: expectedContext, + }, + ); + expect(runner.start).toHaveBeenCalledWith({ + inputData: expectedInput, + context: workflowContext, + memory: workflow.definition.memory, + }); + expect(workflowRunService.markFinished).toHaveBeenCalledWith( + populatedRun.id, + { + snapshot: { status: 'finished', actions: {} }, + memory: runnerState.memory, + context: expectedContext, + output: { result: 'done' }, + }, + ); + expect(workflowRunService.updateOne).toHaveBeenCalledWith(populatedRun.id, { + input: runnerState.input, + output: { result: 'done' }, + memory: runnerState.memory, + metadata: { + state: { + iteration: runnerState.iteration, + accumulator: runnerState.accumulator, + iterationStack: runnerState.iterationStack, + }, + }, + context: expectedContext, + }); + }); + + it('warns when no workflow is available', async () => { + const subscriber = { id: 'subscriber-3' } as Subscriber; + const event = buildEvent(subscriber); + workflowRunService.findSuspendedRunBySubscriber.mockResolvedValueOnce(null); + workflowService.pickWorkflow.mockResolvedValueOnce(null); + + await service.handleMessageEvent(event); + + expect(logger.warn).toHaveBeenCalledWith( + 'No workflow available to handle incoming event', + ); + expect(workflowService.pickWorkflow).toHaveBeenCalled(); + expect(workflowRunService.create).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/workflow/services/agentic.service.ts b/packages/api/src/workflow/services/agentic.service.ts new file mode 100644 index 000000000..83034bb00 --- /dev/null +++ b/packages/api/src/workflow/services/agentic.service.ts @@ -0,0 +1,418 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { + Workflow as AgentWorkflow, + ExecutionState, + WorkflowDefinition, + WorkflowRunner, +} from '@hexabot-ai/agentic'; +import { Injectable } from '@nestjs/common'; + +import { ActionService } from '@/actions/actions.service'; +import EventWrapper from '@/channel/lib/EventWrapper'; +import { Subscriber } from '@/chat/dto/subscriber.dto'; +import { LoggerService } from '@/logger/logger.service'; +import { WorkflowRunFull } from '@/workflow/dto/workflow-run.dto'; +import { Workflow as WorkflowDto } from '@/workflow/dto/workflow.dto'; + +import { RunStrategy, RunWorkflowOptions, WorkflowResult } from '../types'; + +import { WorkflowContext } from './workflow-context'; +import { WorkflowRunService } from './workflow-run.service'; +import { WorkflowService } from './workflow.service'; + +@Injectable() +export class AgenticService { + constructor( + private readonly workflowService: WorkflowService, + private readonly workflowRunService: WorkflowRunService, + private readonly actionService: ActionService, + private readonly logger: LoggerService, + private readonly workflowContext: WorkflowContext, + ) {} + + /** + * Process an incoming channel event by resuming a suspended workflow run if it exists, + * otherwise start a new run using the latest configured workflow (or the default fallback). + */ + async handleMessageEvent(event: EventWrapper) { + const subscriber = event.getSender(); + if (!subscriber) { + this.logger.warn( + 'Skipping workflow execution due to missing subscriber on event', + ); + + return; + } + + try { + const suspendedRun = + await this.workflowRunService.findSuspendedRunBySubscriber( + subscriber.id, + ); + if (suspendedRun) { + await this.runWorkflow({ mode: 'resume', run: suspendedRun, event }); + + return; + } + + const workflow = await this.workflowService.pickWorkflow(); + if (!workflow) { + this.logger.warn('No workflow available to handle incoming event'); + + return; + } + + await this.runWorkflow({ + mode: 'start', + workflow, + subscriber, + event, + }); + } catch (err) { + this.logger.error( + 'Unable to process incoming event through agentic workflow', + err, + ); + } + } + + /** + * Shared runner lifecycle for starting or resuming a workflow. + */ + private async runWorkflow(options: RunWorkflowOptions) { + const { event } = options; + const run = + options.mode === 'start' + ? await this.createRun(options.workflow, options.subscriber, event) + : options.run; + const workflowInstance = this.buildWorkflow(run.workflow.definition); + const context = this.workflowContext.buildFromRun(run, event); + const contextState = + context && Object.keys(context.state ?? {}).length > 0 + ? context.state + : null; + const strategy = await this.createRunStrategy( + options.mode, + run, + context, + workflowInstance, + event, + ); + + await this.workflowRunService.markRunning(run.id, { + ...strategy.markRunningInput, + context: contextState, + }); + + let result: WorkflowResult; + try { + result = await strategy.execute(); + } catch (err) { + await this.markRunFailed(run, strategy.runner, contextState, err); + + throw err; + } + + await this.persistResult( + run, + strategy.runner, + result, + strategy.resumeData, + context, + ); + } + + private async createRunStrategy( + mode: RunWorkflowOptions['mode'], + run: WorkflowRunFull, + context: WorkflowContext, + workflowInstance: AgentWorkflow, + event: EventWrapper, + ): Promise { + if (mode === 'start') { + const runner = await workflowInstance.buildAsyncRunner({ + runId: run.id, + }); + const memory = run.memory ?? run.workflow.definition.memory ?? {}; + + return { + runner, + markRunningInput: { + snapshot: run.snapshot ?? null, + memory, + }, + execute: () => + runner.start({ + inputData: run.input ?? {}, + context, + memory, + }), + }; + } + + const runner = await workflowInstance.buildRunnerFromState({ + state: this.buildExecutionState(run), + context, + snapshot: run.snapshot ?? { status: run.status, actions: {} }, + suspension: run.suspendedStep + ? { + stepId: run.suspendedStep, + reason: run.suspensionReason, + data: run.suspensionData ?? undefined, + } + : undefined, + runId: run.id, + lastResumeData: run.lastResumeData, + }); + const resumeData = this.buildResumeData(event); + + return { + runner, + resumeData, + markRunningInput: { + lastResumeData: resumeData, + snapshot: run.snapshot ?? null, + memory: run.memory ?? null, + }, + execute: () => runner.resume({ resumeData }), + }; + } + + /** + * Create a workflow run record and load it with relations. + */ + private async createRun( + workflow: WorkflowDto, + subscriber: Subscriber, + event: EventWrapper, + ): Promise { + const run = await this.workflowRunService.create({ + workflow: workflow.id, + subscriber: subscriber.id, + input: this.buildInput(event), + memory: workflow.definition.memory ?? null, + context: workflow.definition.context ?? null, + metadata: { channel: event.getChannelData() }, + }); + const populated = await this.workflowRunService.findOneAndPopulate(run.id); + + if (!populated) { + throw new Error(`Unable to load workflow run ${run.id}`); + } + + return populated; + } + + /** + * Persist workflow outcome and updated execution state. + */ + private async persistResult( + run: WorkflowRunFull, + runner: WorkflowRunner, + result: WorkflowResult, + resumeData?: unknown, + runtimeContext?: WorkflowContext, + ) { + const state = this.getRunnerState(runner); + const metadata = this.buildMetadata(state, run.metadata); + const output = this.pickOutput(result, state, run.output); + const context = + runtimeContext && Object.keys(runtimeContext.state ?? {}).length > 0 + ? runtimeContext.state + : null; + + switch (result.status) { + case 'suspended': + await this.workflowRunService.markSuspended(run.id, { + stepId: result.step.id, + reason: result.reason, + data: result.data, + snapshot: result.snapshot, + memory: state?.memory ?? null, + context, + lastResumeData: resumeData, + }); + break; + case 'finished': + await this.workflowRunService.markFinished(run.id, { + snapshot: result.snapshot, + memory: state?.memory ?? null, + context, + output: result.output ?? output, + }); + break; + case 'failed': + default: + await this.workflowRunService.markFailed(run.id, { + snapshot: result.snapshot, + memory: state?.memory ?? null, + context, + error: this.stringifyError(result.error), + }); + break; + } + + await this.workflowRunService.updateOne(run.id, { + input: state?.input ?? run.input ?? {}, + output, + memory: state?.memory ?? run.memory ?? null, + metadata, + context, + }); + } + + /** + * Prepare a workflow instance from its definition and registered actions. + */ + private buildWorkflow(definition: WorkflowDefinition) { + return AgentWorkflow.fromDefinition( + definition, + this.buildActionsRegistry(), + ); + } + + /** + * Build the ExecutionState used to rebuild a runner. + */ + private buildExecutionState(run: WorkflowRunFull): ExecutionState { + const state: ExecutionState = { + input: run.input ?? {}, + memory: run.memory ?? {}, + output: run.output ?? {}, + iterationStack: [], + }; + const storedState = (run.metadata as any)?.state; + if (storedState) { + if (storedState.iteration !== undefined) { + state.iteration = storedState.iteration; + } + if (storedState.accumulator !== undefined) { + state.accumulator = storedState.accumulator; + } + state.iterationStack = storedState.iterationStack ?? []; + } + + return state; + } + + /** + * Build the workflow input payload from the incoming event. + */ + private buildInput(event: EventWrapper) { + const input: Record = { + channel: event.getChannelData(), + message_type: event.getMessageType(), + event_type: event.getEventType(), + sender: event.getSender(), + payload: this.safeInvoke(() => event.getPayload()), + message: this.safeInvoke(() => event.getMessage()), + text: event.getText(), + }; + const id = this.safeInvoke(() => event.getId()); + if (id) { + input.mid = id; + } + + return input; + } + + /** + * Extract the resume payload expected by messaging actions. + */ + private buildResumeData(event: EventWrapper) { + return this.safeInvoke(() => event.getMessage()); + } + + /** + * Build a mapping of registered actions keyed by their names. + */ + private buildActionsRegistry() { + return Object.fromEntries( + this.actionService.getAll().map((action) => [action.getName(), action]), + ); + } + + private safeInvoke(fn: () => T): T | undefined { + try { + const value = fn(); + + return value === undefined ? undefined : value; + } catch { + return undefined; + } + } + + private getRunnerState(runner: WorkflowRunner): ExecutionState | undefined { + return (runner as any).state as ExecutionState | undefined; + } + + private buildMetadata( + state: ExecutionState | undefined, + existing?: Record | null, + ): Record | null { + const next = { ...(existing ?? {}) }; + if (state) { + next.state = { + iteration: state.iteration, + accumulator: state.accumulator, + iterationStack: state.iterationStack, + }; + } + + return Object.keys(next).length > 0 ? next : null; + } + + private pickOutput( + result: WorkflowResult, + state: ExecutionState | undefined, + fallback?: Record | null, + ): Record | null { + if (result.status === 'finished' && result.output) { + return result.output; + } + + if (state?.output) { + return state.output; + } + + return fallback ?? null; + } + + private async markRunFailed( + run: WorkflowRunFull, + runner: WorkflowRunner, + contextState: Record | null, + error: unknown, + ) { + const state = this.getRunnerState(runner); + const metadata = this.buildMetadata(state, run.metadata); + + await this.workflowRunService.markFailed(run.id, { + snapshot: runner.getSnapshot(), + memory: state?.memory ?? null, + context: contextState, + error: this.stringifyError(error), + }); + + await this.workflowRunService.updateOne(run.id, { + input: state?.input ?? run.input ?? {}, + output: state?.output ?? run.output ?? null, + memory: state?.memory ?? run.memory ?? null, + metadata, + context: contextState, + }); + } + + private stringifyError(error: unknown) { + if (error instanceof Error) { + return error.message; + } + + return typeof error === 'string' ? error : JSON.stringify(error); + } +} diff --git a/packages/api/src/workflow/services/workflow-context.ts b/packages/api/src/workflow/services/workflow-context.ts new file mode 100644 index 000000000..55aad5940 --- /dev/null +++ b/packages/api/src/workflow/services/workflow-context.ts @@ -0,0 +1,119 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { BaseWorkflowContext } from '@hexabot-ai/agentic'; +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +import EventWrapper from '@/channel/lib/EventWrapper'; +import { Context } from '@/chat/types/context'; +import { I18nService } from '@/i18n/services/i18n.service'; +import { LoggerService } from '@/logger/logger.service'; +import { SettingService } from '@/setting/services/setting.service'; +import type { WorkflowRunFull } from '@/workflow/dto/workflow-run.dto'; +import { WorkflowContextState } from '@/workflow/types'; + +@Injectable({ scope: Scope.TRANSIENT }) +export class WorkflowContext extends BaseWorkflowContext< + WorkflowContextState, + EventEmitter2 +> { + event?: EventWrapper; + + @Inject(I18nService) + private readonly i18n: I18nService; + + @Inject(SettingService) + private readonly settings: SettingService; + + @Inject(LoggerService) + private readonly logger: LoggerService; + + @Inject(EventEmitter2) + readonly eventEmitter: EventEmitter2; + + get services() { + return { + i18n: this.i18n, + settings: this.settings, + logger: this.logger, + }; + } + + get subscriberId(): string | undefined { + return this.state.subscriberId as string | undefined; + } + + set subscriberId(value: string | undefined) { + this.state.subscriberId = value; + } + + get workflowId(): string | undefined { + return this.state.workflowId as string | undefined; + } + + set workflowId(value: string | undefined) { + this.state.workflowId = value; + } + + get chatContext(): Context | undefined { + return this.state.chatContext as Context | undefined; + } + + set chatContext(value: Context | undefined) { + this.state.chatContext = value; + } + + get workflowRunId(): string | undefined { + return this.state.runId as string | undefined; + } + + set workflowRunId(value: string | undefined) { + this.state.runId = value; + } + + get runId(): string | undefined { + return this.state.runId as string | undefined; + } + + set runId(value: string | undefined) { + this.state.runId = value; + } + + buildFromRun(run: WorkflowRunFull, event: EventWrapper): this { + this.hydrate(run.context); + const legacyContext = (this.state as any).conversationContext as + | Context + | undefined; + if (legacyContext && !this.chatContext) { + this.chatContext = legacyContext; + } + const legacyConversationId = (this.state as any).conversationId as + | string + | undefined; + if (legacyConversationId && !this.runId) { + this.runId = legacyConversationId; + } + delete (this.state as any).conversationContext; + delete (this.state as any).conversationId; + this.event = event; + this.subscriberId = run.subscriber?.id; + this.workflowId = run.workflow.id; + this.workflowRunId = run.id; + + return this; + } + + hydrate(stored?: WorkflowContextState | null): this { + if (!stored) { + return this; + } + + this.state = { ...this.state, ...stored }; + + return this; + } +} diff --git a/packages/api/src/workflow/services/workflow-run.service.spec.ts b/packages/api/src/workflow/services/workflow-run.service.spec.ts new file mode 100644 index 000000000..813f3757a --- /dev/null +++ b/packages/api/src/workflow/services/workflow-run.service.spec.ts @@ -0,0 +1,292 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowDefinition, WorkflowSnapshot } from '@hexabot-ai/agentic'; +import { TestingModule } from '@nestjs/testing'; + +import { closeTypeOrmConnections } from '@/utils/test/test'; +import { buildTestingMocks } from '@/utils/test/utils'; + +import { WorkflowRun } from '../dto/workflow-run.dto'; +import { Workflow } from '../dto/workflow.dto'; +import { WorkflowRunOrmEntity } from '../entities/workflow-run.entity'; +import { WorkflowOrmEntity } from '../entities/workflow.entity'; +import { WorkflowRunRepository } from '../repositories/workflow-run.repository'; +import { WorkflowRepository } from '../repositories/workflow.repository'; + +import { WorkflowRunService } from './workflow-run.service'; +import { WorkflowService } from './workflow.service'; + +describe('WorkflowRunService (TypeORM)', () => { + let module: TestingModule; + let workflowService: WorkflowService; + let workflowRepository: WorkflowRepository; + let workflowRunService: WorkflowRunService; + let workflowRunRepository: WorkflowRunRepository; + let workflow: Workflow; + let workflowRun: WorkflowRun; + let counter = 0; + + const buildWorkflowDefinition = (): WorkflowDefinition => ({ + workflow: { + name: `Run workflow ${++counter}`, + version: `0.0.${counter}`, + }, + tasks: { + greet: { action: 'greet' }, + }, + flow: [{ do: 'greet' }], + outputs: { result: '=1' }, + }); + const snapshot: WorkflowSnapshot = { + status: 'running', + actions: { + greet: { + id: 'greet', + name: 'Greet', + status: 'running', + }, + }, + }; + + beforeAll(async () => { + const testing = await buildTestingMocks({ + autoInjectFrom: ['providers'], + providers: [ + WorkflowService, + WorkflowRepository, + WorkflowRunService, + WorkflowRunRepository, + ], + typeorm: { + entities: [WorkflowOrmEntity, WorkflowRunOrmEntity], + }, + }); + + module = testing.module; + [ + workflowService, + workflowRepository, + workflowRunService, + workflowRunRepository, + ] = await testing.getMocks([ + WorkflowService, + WorkflowRepository, + WorkflowRunService, + WorkflowRunRepository, + ]); + }); + + beforeEach(async () => { + await workflowRunRepository.deleteMany(); + await workflowRepository.deleteMany(); + + const definition = buildWorkflowDefinition(); + workflow = await workflowService.create({ + name: definition.workflow.name, + version: definition.workflow.version, + definition, + description: 'Workflow for run tests', + }); + + workflowRun = await workflowRunService.create({ + workflow: workflow.id, + status: 'idle', + input: { foo: 'bar' }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + afterAll(async () => { + if (module) { + await module.close(); + } + await closeTypeOrmConnections(); + }); + + describe('state transitions', () => { + it('marks a run as running and forwards state', async () => { + const payload = { + snapshot, + memory: { count: 1 }, + context: { user: 'demo' }, + lastResumeData: { resumed: true }, + }; + const updateSpy = jest.spyOn(workflowRunRepository, 'updateOne'); + const updated = await workflowRunService.markRunning( + workflowRun.id, + payload, + ); + const stored = await workflowRunService.findOne(workflowRun.id); + + expect(updateSpy).toHaveBeenCalledWith( + workflowRun.id, + { + status: 'running', + ...payload, + }, + undefined, + ); + expect(updated.status).toBe('running'); + expect(updated.snapshot).toEqual(payload.snapshot); + expect(updated.memory).toEqual(payload.memory); + expect(updated.context).toEqual(payload.context); + expect(updated.lastResumeData).toEqual(payload.lastResumeData); + expect(stored?.status).toBe('running'); + }); + + it('marks a run as suspended with metadata and timestamps', async () => { + const now = new Date('2024-10-10T10:00:00Z'); + jest.useFakeTimers().setSystemTime(now); + const payload = { + stepId: 'greet', + reason: 'awaiting input', + data: { question: 'name' }, + lastResumeData: { source: 'suspend' }, + snapshot, + memory: { step: 'greet' }, + context: { lang: 'en' }, + }; + const updateSpy = jest.spyOn(workflowRunRepository, 'updateOne'); + const updated = await workflowRunService.markSuspended( + workflowRun.id, + payload, + ); + + expect(updateSpy).toHaveBeenCalledWith( + workflowRun.id, + { + status: 'suspended', + suspendedStep: payload.stepId, + suspensionReason: payload.reason, + suspensionData: payload.data, + lastResumeData: payload.lastResumeData, + suspendedAt: now, + snapshot, + memory: payload.memory, + context: payload.context, + }, + undefined, + ); + expect(updated.status).toBe('suspended'); + expect(updated.suspendedStep).toBe(payload.stepId); + expect(updated.suspensionReason).toBe(payload.reason); + expect(updated.suspensionData).toEqual(payload.data); + expect(updated.lastResumeData).toEqual(payload.lastResumeData); + expect(updated.suspendedAt?.getTime()).toBe(now.getTime()); + }); + + it('marks a run as finished with outputs', async () => { + const now = new Date('2024-11-11T09:00:00Z'); + jest.useFakeTimers().setSystemTime(now); + const payload = { + snapshot, + memory: { done: true }, + context: { total: 1 }, + output: { message: 'complete' }, + }; + const updateSpy = jest.spyOn(workflowRunRepository, 'updateOne'); + const updated = await workflowRunService.markFinished( + workflowRun.id, + payload, + ); + const stored = await workflowRunService.findOne(workflowRun.id); + + expect(updateSpy).toHaveBeenCalledWith( + workflowRun.id, + { + status: 'finished', + finishedAt: now, + ...payload, + }, + undefined, + ); + expect(updated.status).toBe('finished'); + expect(updated.finishedAt?.getTime()).toBe(now.getTime()); + expect(updated.output).toEqual(payload.output); + expect(stored?.status).toBe('finished'); + expect(stored?.finishedAt?.getTime()).toBe(now.getTime()); + }); + + it('marks a run as failed and captures errors', async () => { + const now = new Date('2024-12-12T08:00:00Z'); + jest.useFakeTimers().setSystemTime(now); + const payload = { + snapshot, + memory: { stage: 'error' }, + context: { attempt: 1 }, + error: 'Unexpected failure', + }; + const updateSpy = jest.spyOn(workflowRunRepository, 'updateOne'); + const updated = await workflowRunService.markFailed( + workflowRun.id, + payload, + ); + const stored = await workflowRunService.findOne(workflowRun.id); + + expect(updateSpy).toHaveBeenCalledWith( + workflowRun.id, + { + status: 'failed', + failedAt: now, + ...payload, + }, + undefined, + ); + expect(updated.status).toBe('failed'); + expect(updated.failedAt?.getTime()).toBe(now.getTime()); + expect(updated.error).toBe(payload.error); + expect(stored?.status).toBe('failed'); + expect(stored?.failedAt?.getTime()).toBe(now.getTime()); + }); + }); + + describe('population helpers', () => { + it('populates workflow relations on findOneAndPopulate', async () => { + const populated = await workflowRunService.findOneAndPopulate( + workflowRun.id, + ); + + expect(populated).not.toBeNull(); + expect(populated!.workflow.id).toBe(workflow.id); + expect(populated!.workflow.name).toBe(workflow.name); + }); + + it('populates workflow relations for bulk queries', async () => { + const populated = await workflowRunService.findAndPopulate(); + + expect(populated).toHaveLength(1); + expect(populated[0]!.workflow.id).toBe(workflow.id); + expect(populated[0]!.workflow.version).toBe(workflow.version); + }); + }); + + describe('findSuspendedRunBySubscriber', () => { + it('delegates to repository with subscriber filter and ordering', async () => { + const mockedRepo = { + findOneAndPopulate: jest.fn(), + } as unknown as WorkflowRunRepository; + const service = new WorkflowRunService(mockedRepo); + const expectedRun = { id: 'run-123' } as WorkflowRun; + + (mockedRepo.findOneAndPopulate as jest.Mock).mockResolvedValue( + expectedRun, + ); + + const result = await service.findSuspendedRunBySubscriber('sub-1'); + + expect(mockedRepo.findOneAndPopulate).toHaveBeenCalledWith({ + where: { subscriber: { id: 'sub-1' }, status: 'suspended' }, + order: { suspendedAt: 'DESC', createdAt: 'DESC' }, + }); + expect(result).toBe(expectedRun); + }); + }); +}); diff --git a/packages/api/src/workflow/services/workflow-run.service.ts b/packages/api/src/workflow/services/workflow-run.service.ts new file mode 100644 index 000000000..26e7dc7a8 --- /dev/null +++ b/packages/api/src/workflow/services/workflow-run.service.ts @@ -0,0 +1,139 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowSnapshot } from '@hexabot-ai/agentic'; +import { Injectable } from '@nestjs/common'; + +import { BaseOrmService } from '@/utils/generics/base-orm.service'; + +import { + WorkflowRun, + WorkflowRunDtoConfig, + WorkflowRunFull, + WorkflowRunTransformerDto, +} from '../dto/workflow-run.dto'; +import { WorkflowRunOrmEntity } from '../entities/workflow-run.entity'; +import { WorkflowRunRepository } from '../repositories/workflow-run.repository'; + +type StateUpdate = { + snapshot?: WorkflowSnapshot | null; + memory?: Record | null; + context?: Record | null; +}; + +@Injectable() +export class WorkflowRunService extends BaseOrmService< + WorkflowRunOrmEntity, + WorkflowRunTransformerDto, + WorkflowRunDtoConfig, + WorkflowRunRepository +> { + /** + * Creates the service with the underlying repository injected. + * + * @param repository - ORM repository used to persist workflow run entities. + */ + constructor(readonly repository: WorkflowRunRepository) { + super(repository); + } + + /** + * Mark a run as running and persist optional execution state. + * + * @param runId - Identifier of the run to update. + * @param payload - State changes such as snapshot, memory, context, or resume data. + * @returns Updated workflow run marked as `running`. + */ + async markRunning( + runId: string, + payload: StateUpdate & { lastResumeData?: unknown }, + ): Promise { + return await this.updateOne(runId, { + status: 'running', + ...payload, + }); + } + + /** + * Mark a run as suspended with the corresponding reason and state. + * + * @param runId - Identifier of the run to update. + * @param payload - Suspension metadata (step, reason, data) plus optional state updates. + * @returns Updated workflow run marked as `suspended`. + */ + async markSuspended( + runId: string, + payload: { + stepId?: string | null; + reason?: string | null; + data?: unknown; + lastResumeData?: unknown; + } & StateUpdate, + ): Promise { + const { stepId, reason, data, lastResumeData, ...state } = payload; + + return await this.updateOne(runId, { + status: 'suspended', + suspendedStep: stepId ?? null, + suspensionReason: reason ?? null, + suspensionData: data, + lastResumeData, + suspendedAt: new Date(), + ...state, + }); + } + + /** + * Mark a run as finished and store final state/output. + * + * @param runId - Identifier of the run to update. + * @param payload - Final state changes and optional output payload. + * @returns Updated workflow run marked as `finished`. + */ + async markFinished( + runId: string, + payload: StateUpdate & { output?: Record | null }, + ): Promise { + return await this.updateOne(runId, { + status: 'finished', + finishedAt: new Date(), + ...payload, + }); + } + + /** + * Mark a run as failed and persist the failure reason and state. + * + * @param runId - Identifier of the run to update. + * @param payload - Optional error message and state changes. + * @returns Updated workflow run marked as `failed`. + */ + async markFailed( + runId: string, + payload: StateUpdate & { error?: string | null }, + ): Promise { + return await this.updateOne(runId, { + status: 'failed', + failedAt: new Date(), + ...payload, + }); + } + + /** + * Find the latest suspended run for a subscriber. + * + * @param subscriberId - Identifier of the subscriber whose suspended run should be fetched. + * @returns The most recently suspended run populated with relations, or `null` when none exists. + */ + async findSuspendedRunBySubscriber( + subscriberId: string, + ): Promise { + return await this.findOneAndPopulate({ + where: { subscriber: { id: subscriberId }, status: 'suspended' }, + order: { suspendedAt: 'DESC', createdAt: 'DESC' }, + }); + } +} diff --git a/packages/api/src/workflow/services/workflow.service.spec.ts b/packages/api/src/workflow/services/workflow.service.spec.ts new file mode 100644 index 000000000..c24cb41bc --- /dev/null +++ b/packages/api/src/workflow/services/workflow.service.spec.ts @@ -0,0 +1,131 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { WorkflowDefinition } from '@hexabot-ai/agentic'; +import { TestingModule } from '@nestjs/testing'; + +import { closeTypeOrmConnections } from '@/utils/test/test'; +import { buildTestingMocks } from '@/utils/test/utils'; + +import defaultWorkflowDefinition from '../defaults/default-workflow'; +import { Workflow } from '../dto/workflow.dto'; +import { WorkflowRunOrmEntity } from '../entities/workflow-run.entity'; +import { WorkflowOrmEntity } from '../entities/workflow.entity'; +import { WorkflowRunRepository } from '../repositories/workflow-run.repository'; +import { WorkflowRepository } from '../repositories/workflow.repository'; + +import { WorkflowRunService } from './workflow-run.service'; +import { WorkflowService } from './workflow.service'; + +describe('WorkflowService (TypeORM)', () => { + let module: TestingModule; + let workflowService: WorkflowService; + let workflowRepository: WorkflowRepository; + let workflow: Workflow; + let counter = 0; + + const buildWorkflowDefinition = (): WorkflowDefinition => ({ + workflow: { + name: `Test workflow ${++counter}`, + version: `1.0.${counter}`, + description: 'Test workflow definition', + }, + tasks: { + greet: { action: 'greet' }, + }, + flow: [{ do: 'greet' }], + outputs: { result: '=1' }, + }); + + beforeAll(async () => { + const testing = await buildTestingMocks({ + autoInjectFrom: ['providers'], + providers: [ + WorkflowService, + WorkflowRepository, + WorkflowRunService, + WorkflowRunRepository, + ], + typeorm: { + entities: [WorkflowOrmEntity, WorkflowRunOrmEntity], + }, + }); + + module = testing.module; + [workflowService, workflowRepository] = await testing.getMocks([ + WorkflowService, + WorkflowRepository, + ]); + }); + + beforeEach(async () => { + await workflowRepository.deleteMany(); + + const definition = buildWorkflowDefinition(); + workflow = await workflowService.create({ + name: definition.workflow.name, + version: definition.workflow.version, + description: definition.workflow.description, + definition, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + afterAll(async () => { + if (module) { + await module.close(); + } + await closeTypeOrmConnections(); + }); + + it('creates workflows and returns stored definitions', async () => { + const stored = await workflowService.findOne(workflow.id); + + expect(stored).not.toBeNull(); + expect(stored).toMatchObject({ + id: workflow.id, + name: workflow.name, + version: workflow.version, + description: workflow.description, + definition: workflow.definition, + }); + }); + + it('enforces unique name/version pairs', async () => { + const duplicatePayload = { + name: workflow.name, + version: workflow.version, + description: workflow.description ?? undefined, + definition: workflow.definition, + }; + + await expect(workflowService.create(duplicatePayload)).rejects.toThrow(); + }); + + it('returns the latest workflow when one exists', async () => { + const picked = await workflowService.pickWorkflow(); + + expect(picked?.id).toBe(workflow.id); + expect(picked?.name).toBe(workflow.name); + }); + + it('creates and returns the default workflow when none exist', async () => { + await workflowRepository.deleteMany(); + + const picked = await workflowService.pickWorkflow(); + + expect(picked).not.toBeNull(); + expect(picked).toMatchObject({ + name: defaultWorkflowDefinition.workflow.name, + version: defaultWorkflowDefinition.workflow.version, + description: defaultWorkflowDefinition.workflow.description, + }); + }); +}); diff --git a/packages/api/src/workflow/services/workflow.service.ts b/packages/api/src/workflow/services/workflow.service.ts new file mode 100644 index 000000000..9f5445945 --- /dev/null +++ b/packages/api/src/workflow/services/workflow.service.ts @@ -0,0 +1,79 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { Injectable } from '@nestjs/common'; + +import { BaseOrmService } from '@/utils/generics/base-orm.service'; + +import defaultWorkflowDefinition from '../defaults/default-workflow'; +import { + Workflow as WorkflowDto, + WorkflowDtoConfig, + WorkflowTransformerDto, +} from '../dto/workflow.dto'; +import { WorkflowOrmEntity } from '../entities/workflow.entity'; +import { WorkflowRepository } from '../repositories/workflow.repository'; + +@Injectable() +export class WorkflowService extends BaseOrmService< + WorkflowOrmEntity, + WorkflowTransformerDto, + WorkflowDtoConfig, + WorkflowRepository +> { + /** + * Creates the workflow service with the injected repository. + * + * @param repository - ORM repository used to manage workflow entities. + */ + constructor(readonly repository: WorkflowRepository) { + super(repository); + } + + /** + * Pick the most recently created workflow or fall back to the built-in default. + */ + async pickWorkflow(): Promise { + const [latest] = await this.find({ + order: { createdAt: 'DESC' }, + take: 1, + }); + const workflow = latest ?? (await this.ensureDefaultWorkflow()); + + return workflow; + } + + /** + * Ensure a default workflow exists and return it when none are stored. + * + * @returns The existing or newly created default workflow, or `null` when creation fails. + */ + private async ensureDefaultWorkflow(): Promise { + try { + const existing = await this.findOne({ + where: { + name: defaultWorkflowDefinition.workflow.name, + version: defaultWorkflowDefinition.workflow.version, + }, + }); + + if (existing) { + return existing; + } + + return await this.create({ + name: defaultWorkflowDefinition.workflow.name, + version: defaultWorkflowDefinition.workflow.version, + description: defaultWorkflowDefinition.workflow.description, + definition: defaultWorkflowDefinition, + }); + } catch (err) { + this.logger.error('Unable to ensure default workflow exists', err); + + return null; + } + } +} diff --git a/packages/api/src/workflow/types.ts b/packages/api/src/workflow/types.ts new file mode 100644 index 000000000..670600d0a --- /dev/null +++ b/packages/api/src/workflow/types.ts @@ -0,0 +1,54 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { + WorkflowResumeResult, + WorkflowRunner, + WorkflowSnapshot, + WorkflowStartResult, +} from '@hexabot-ai/agentic'; + +import EventWrapper from '@/channel/lib/EventWrapper'; +import { Subscriber } from '@/chat/dto/subscriber.dto'; +import { Context } from '@/chat/types/context'; + +import { WorkflowRunFull } from './dto/workflow-run.dto'; +import { Workflow } from './dto/workflow.dto'; + +export type WorkflowResult = WorkflowStartResult | WorkflowResumeResult; + +export type RunWorkflowOptions = + | { + mode: 'start'; + workflow: Workflow; + subscriber: Subscriber; + event: EventWrapper; + } + | { + mode: 'resume'; + run: WorkflowRunFull; + event: EventWrapper; + }; + +export type MarkRunningInput = { + snapshot?: WorkflowSnapshot | null; + memory?: Record | null; + lastResumeData?: unknown; +}; + +export type RunStrategy = { + runner: WorkflowRunner; + markRunningInput: MarkRunningInput; + resumeData?: unknown; + execute: () => Promise; +}; + +export type WorkflowContextState = Record & { + subscriberId?: string; + workflowId?: string; + chatContext?: Context; + runId?: string; +}; diff --git a/packages/api/src/workflow/workflow.module.ts b/packages/api/src/workflow/workflow.module.ts new file mode 100644 index 000000000..f66f061b4 --- /dev/null +++ b/packages/api/src/workflow/workflow.module.ts @@ -0,0 +1,42 @@ +/* + * Hexabot — Fair Core License (FCL-1.0-ALv2) + * Copyright (c) 2025 Hexastack. + * Full terms: see LICENSE.md. + */ + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WorkflowController } from './controllers/workflow.controller'; +import { WorkflowRunOrmEntity } from './entities/workflow-run.entity'; +import { WorkflowOrmEntity } from './entities/workflow.entity'; +import { WorkflowRunRepository } from './repositories/workflow-run.repository'; +import { WorkflowRepository } from './repositories/workflow.repository'; +import { AgenticService } from './services/agentic.service'; +import { WorkflowContext } from './services/workflow-context'; +import { WorkflowRunService } from './services/workflow-run.service'; +import { WorkflowService } from './services/workflow.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([WorkflowOrmEntity, WorkflowRunOrmEntity]), + ], + controllers: [WorkflowController], + providers: [ + WorkflowRepository, + WorkflowRunRepository, + WorkflowService, + WorkflowRunService, + WorkflowContext, + AgenticService, + ], + exports: [ + WorkflowRepository, + WorkflowRunRepository, + WorkflowService, + WorkflowRunService, + WorkflowContext, + AgenticService, + ], +}) +export class WorkflowModule {} diff --git a/packages/api/types/event-emitter.d.ts b/packages/api/types/event-emitter.d.ts index 37a205815..94ad9e325 100644 --- a/packages/api/types/event-emitter.d.ts +++ b/packages/api/types/event-emitter.d.ts @@ -4,6 +4,7 @@ * Full terms: see LICENSE.md. */ +import type { StepInfo } from '@hexabot-ai/agentic'; import type { HttpException } from '@nestjs/common'; import type { OnEventType } from '@nestjs/event-emitter'; import type { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; @@ -25,22 +26,16 @@ import type { } from '@/analytics/entities/bot-stats.entity'; import type { AttachmentOrmEntity } from '@/attachment/entities/attachment.entity'; import type EventWrapper from '@/channel/lib/EventWrapper'; -import type { BlockFull } from '@/chat/dto/block.dto'; -import type { Conversation } from '@/chat/dto/conversation.dto'; import type { Message, MessageCreateDto } from '@/chat/dto/message.dto'; import type { Subscriber, SubscriberUpdateDto, } from '@/chat/dto/subscriber.dto'; -import type { BlockOrmEntity } from '@/chat/entities/block.entity'; -import type { CategoryOrmEntity } from '@/chat/entities/category.entity'; import type { ContextVarOrmEntity } from '@/chat/entities/context-var.entity'; -import type { ConversationOrmEntity } from '@/chat/entities/conversation.entity'; import type { LabelGroupOrmEntity } from '@/chat/entities/label-group.entity'; import type { LabelOrmEntity } from '@/chat/entities/label.entity'; import type { MessageOrmEntity } from '@/chat/entities/message.entity'; import type { SubscriberOrmEntity } from '@/chat/entities/subscriber.entity'; -import type { Context } from '@/chat/types/context'; import type { ContentTypeOrmEntity } from '@/cms/entities/content-type.entity'; import type { ContentOrmEntity } from '@/cms/entities/content.entity'; import type { MenuOrmEntity } from '@/cms/entities/menu.entity'; @@ -61,6 +56,8 @@ import type { RoleOrmEntity } from '@/user/entities/role.entity'; import type { UserOrmEntity } from '@/user/entities/user.entity'; import type { DummyOrmEntity } from '@/utils/test/dummy/entities/dummy.entity'; import type { THydratedDocument } from '@/utils/types/filter.types'; +import type { WorkflowRunOrmEntity } from '@/workflow/entities/workflow-run.entity'; +import type { WorkflowOrmEntity } from '@/workflow/entities/workflow.entity'; type AnyEventWrapper = EventWrapper; @@ -131,12 +128,9 @@ declare module '@nestjs/event-emitter' { interface OrmEntityRegistry { attachment: AttachmentOrmEntity; - block: BlockOrmEntity; botStats: BotStatsOrmEntity; - category: CategoryOrmEntity; content: ContentOrmEntity; contentType: ContentTypeOrmEntity; - conversation: ConversationOrmEntity; contextVar: ContextVarOrmEntity; dummy: DummyOrmEntity; invitation: InvitationOrmEntity; @@ -157,6 +151,8 @@ declare module '@nestjs/event-emitter' { subscriber: SubscriberOrmEntity; translation: TranslationOrmEntity; user: UserOrmEntity; + workflow: WorkflowOrmEntity; + workflowRun: WorkflowRunOrmEntity; } type IHookEntities = keyof OrmEntityRegistry & string; @@ -202,13 +198,6 @@ declare module '@nestjs/event-emitter' { >; interface CustomEventMap { - 'hook:analytics:block': [BlockFull, AnyEventWrapper, Context | undefined]; - 'hook:analytics:fallback-local': [ - BlockFull, - AnyEventWrapper, - Context | undefined, - ]; - 'hook:analytics:fallback-global': [AnyEventWrapper]; 'hook:analytics:passation': [Subscriber, boolean]; 'hook:chatbot:echo': [AnyEventWrapper]; 'hook:chatbot:delivery': [AnyEventWrapper]; @@ -216,8 +205,6 @@ declare module '@nestjs/event-emitter' { 'hook:chatbot:read': [AnyEventWrapper]; 'hook:chatbot:received': [AnyEventWrapper]; 'hook:chatbot:sent': [MessageCreateDto, AnyEventWrapper?]; - 'hook:conversation:close': [string]; - 'hook:conversation:end': [Conversation]; 'hook:message:preCreate': [THydratedDocument]; 'hook:stats:entry': [BotStatsType, string, Subscriber?]; 'hook:subscriber:assign': [SubscriberUpdateDto, Subscriber]; @@ -225,6 +212,21 @@ declare module '@nestjs/event-emitter' { 'hook:user:logout': [ExpressSession]; 'hook:websocket:connection': [Socket]; 'hook:websocket:error': [Socket, Error | HttpException]; + 'hook:workflow:start': [{ runId?: string }]; + 'hook:workflow:finish': [ + { runId?: string; output: Record }, + ]; + 'hook:workflow:failure': [{ runId?: string; error: unknown }]; + 'hook:workflow:suspended': [ + { runId?: string; step: StepInfo; reason?: string; data?: unknown }, + ]; + 'hook:step:start': [{ runId?: string; step: StepInfo }]; + 'hook:step:success': [{ runId?: string; step: StepInfo }]; + 'hook:step:error': [{ runId?: string; step: StepInfo; error: unknown }]; + 'hook:step:suspended': [ + { runId?: string; step: StepInfo; reason?: string; data?: unknown }, + ]; + 'hook:step:skipped': [{ runId?: string; step: StepInfo; reason?: string }]; } interface IBaseHookEventMap diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0ea46d40..a09824870 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,7 +68,7 @@ importers: version: 2.32.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.37.0(jiti@2.6.1)) jest: specifier: ^30.2.0 - version: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + version: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0) lint-staged: specifier: ^15.3.0 version: 15.5.2 @@ -77,13 +77,16 @@ importers: version: 3.6.2 ts-jest: specifier: ^29.4.5 - version: 29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0))(typescript@5.9.3) typescript: specifier: ^5.6.2 version: 5.9.3 packages/api: dependencies: + '@hexabot-ai/agentic': + specifier: workspace:* + version: link:../agentic '@keyv/redis': specifier: ^5.1.3 version: 5.1.3(keyv@5.5.3) @@ -458,7 +461,7 @@ importers: version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1))(prettier@3.6.2) jest: specifier: ^30.2.0 - version: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + version: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0) lint-staged: specifier: ^15.3.0 version: 15.5.2 @@ -467,7 +470,7 @@ importers: version: 3.6.2 ts-jest: specifier: ^29.4.5 - version: 29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0))(typescript@5.9.3) tsx: specifier: ^4.19.2 version: 4.20.6 @@ -10604,42 +10607,6 @@ snapshots: - supports-color - ts-node - '@jest/core@30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': - dependencies: - '@jest/console': 30.2.0 - '@jest/pattern': 30.0.1 - '@jest/reporters': 30.2.0 - '@jest/test-result': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 20.12.12 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 4.3.1 - exit-x: 0.2.2 - graceful-fs: 4.2.11 - jest-changed-files: 30.2.0 - jest-config: 30.2.0(@types/node@20.12.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - jest-haste-map: 30.2.0 - jest-message-util: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-resolve-dependencies: 30.2.0 - jest-runner: 30.2.0 - jest-runtime: 30.2.0 - jest-snapshot: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - jest-watcher: 30.2.0 - micromatch: 4.0.8 - pretty-format: 30.2.0 - slash: 3.0.0 - transitivePeerDependencies: - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - '@jest/diff-sequences@30.0.1': {} '@jest/environment@30.2.0': @@ -15104,15 +15071,15 @@ snapshots: - supports-color - ts-node - jest-cli@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + jest-cli@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0): dependencies: - '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.12.12)(typescript@5.9.3)) '@jest/test-result': 30.2.0 '@jest/types': 30.2.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-config: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0) jest-util: 30.2.0 jest-validate: 30.2.0 yargs: 17.7.2 @@ -15156,40 +15123,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.2.0(@types/node@20.12.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.28.4 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.4) - chalk: 4.1.2 - ci-info: 4.3.1 - deepmerge: 4.3.1 - glob: 10.4.5 - graceful-fs: 4.2.11 - jest-circus: 30.2.0(babel-plugin-macros@3.1.0) - jest-docblock: 30.2.0 - jest-environment-node: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-runner: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 30.2.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.12.12 - ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + jest-config@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0): dependencies: '@babel/core': 7.28.4 '@jest/get-type': 30.1.0 @@ -15217,7 +15151,6 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.19.1 - ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -15456,12 +15389,12 @@ snapshots: - supports-color - ts-node - jest@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + jest@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0): dependencies: - '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.12.12)(typescript@5.9.3)) '@jest/types': 30.2.0 import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-cli: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -18028,12 +17961,12 @@ snapshots: babel-jest: 30.2.0(@babel/core@7.28.4) jest-util: 30.2.0 - ts-jest@29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest: 30.2.0(@types/node@22.19.1)(babel-plugin-macros@3.1.0) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -18081,25 +18014,6 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.1 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optional: true - tsconfig-paths-jest@0.0.1: {} tsconfig-paths-webpack-plugin@4.2.0: