diff --git a/api/.dockerignore b/api/.dockerignore index b55f7aab0..1fa84c279 100644 --- a/api/.dockerignore +++ b/api/.dockerignore @@ -14,3 +14,4 @@ test *.mock.ts __mock__ __test__ +.hexabot diff --git a/api/.gitignore b/api/.gitignore index a3d0bf5f4..e1fa31522 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -1,3 +1,4 @@ +.hexabot/ node_modules/ dist/ coverage/ diff --git a/api/Dockerfile b/api/Dockerfile index 8f057a993..c35c2d60c 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -4,10 +4,6 @@ WORKDIR /app COPY package*.json ./ -COPY merge-extensions-deps.js ./ - -COPY src/extensions ./src/extensions - COPY patches ./patches RUN npm ci @@ -22,8 +18,6 @@ WORKDIR /app COPY package*.json ./ -COPY --from=builder /app/merge-extensions-deps.js ./ - COPY --from=builder /app/src/extensions ./src/extensions COPY --from=builder /app/patches ./patches @@ -44,8 +38,6 @@ WORKDIR /app COPY package*.json ./ -COPY --from=builder /app/merge-extensions-deps.js ./ - COPY --from=builder /app/src/extensions ./src/extensions COPY --from=builder /app/patches ./patches diff --git a/api/merge-extensions-deps.js b/api/merge-extensions-deps.js deleted file mode 100644 index 346cd727e..000000000 --- a/api/merge-extensions-deps.js +++ /dev/null @@ -1,80 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const fs = require('fs'); -// eslint-disable-next-line @typescript-eslint/no-var-requires -const path = require('path'); - -// Define the paths -const rootPackageJsonPath = path.join(__dirname, 'package.json'); -const pluginsDir = path.join(__dirname, 'src', 'extensions', 'plugins'); -const channelsDir = path.join(__dirname, 'src', 'extensions', 'channels'); -const helpersDir = path.join(__dirname, 'src', 'extensions', 'helpers'); - -// Helper function to merge dependencies -function mergeDependencies(rootDeps, pluginDeps) { - return { - ...rootDeps, - ...Object.entries(pluginDeps).reduce((acc, [key, version]) => { - if (!rootDeps[key]) { - acc[key] = version; - } - return acc; - }, {}), - }; -} - -// Read the root package.json -const rootPackageJson = JSON.parse( - fs.readFileSync(rootPackageJsonPath, 'utf-8'), -); - -// Initialize dependencies if not already present -if (!rootPackageJson.dependencies) { - rootPackageJson.dependencies = {}; -} - -// Iterate over extension directories -[ - ...fs.readdirSync(pluginsDir), - ...fs.readdirSync(helpersDir), - ...fs.readdirSync(channelsDir), -].forEach((pluginFolder) => { - const pluginPackageJsonPath = path.join( - pluginsDir, - pluginFolder, - 'package.json', - ); - - if (fs.existsSync(pluginPackageJsonPath)) { - const pluginPackageJson = JSON.parse( - fs.readFileSync(pluginPackageJsonPath, 'utf-8'), - ); - - // Merge extension dependencies into root dependencies - if (pluginPackageJson.dependencies) { - rootPackageJson.dependencies = mergeDependencies( - rootPackageJson.dependencies, - pluginPackageJson.dependencies, - ); - } - - // Merge extension devDependencies into root devDependencies - if (pluginPackageJson.devDependencies) { - rootPackageJson.devDependencies = mergeDependencies( - rootPackageJson.devDependencies, - pluginPackageJson.devDependencies, - ); - } - } -}); - -// Write the updated root package.json -fs.writeFileSync( - rootPackageJsonPath, - JSON.stringify(rootPackageJson, null, 2), - 'utf-8', -); - -// eslint-disable-next-line no-console -console.log( - 'Dependencies from extensions have been merged into the root package.json', -); diff --git a/api/package.json b/api/package.json index 67f15f521..f992ba1e5 100644 --- a/api/package.json +++ b/api/package.json @@ -6,14 +6,19 @@ "author": "Hexastack", "license": "AGPL-3.0-only", "scripts": { - "preinstall": "node merge-extensions-deps.js", "postinstall": "patch-package", - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "build:clean": "rm -rf src/.hexabot", + "build:channels": "mkdir -p src/.hexabot/channels && find node_modules/ -name 'hexabot-channel-*' -exec cp -R {} src/.hexabot/channels/ \\;", + "build:helpers": "mkdir -p src/.hexabot/helpers && find node_modules/ -name 'hexabot-helper-*' -exec cp -R {} src/.hexabot/helpers/ \\;", + "build:plugins": "mkdir -p src/.hexabot/plugins && find node_modules/ -name 'hexabot-plugin-*' -exec cp -R {} src/.hexabot/plugins/ \\;", + "build:extensions": "npm run build:channels && npm run build:helpers && npm run build:plugins", + "build:prepare": "npm run build:clean && npm run build:extensions", + "build": "npm run build:prepare && nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", "start": "nest start", "doc": "npx @compodoc/compodoc --hideGenerator -p tsconfig.doc.json -s -r 9003 -w", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug 0.0.0.0:9229 --watch", + "start:dev": "npm run build:prepare && nest start --watch", + "start:debug": "npm run build:prepare && nest start --debug 0.0.0.0:9229 --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 33d6300c1..3f6077ce2 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -108,13 +108,13 @@ export class AttachmentService extends BaseService { await this.getStoragePlugin().uploadAvatar(picture); this.logger.log( `Profile picture uploaded successfully to ${ - this.getStoragePlugin().id + this.getStoragePlugin().name }`, ); } catch (err) { this.logger.error( `Error while uploading profile picture to ${ - this.getStoragePlugin().id + this.getStoragePlugin().name }`, err, ); diff --git a/api/src/channel/channel.controller.ts b/api/src/channel/channel.controller.ts index e2d74f49c..0a8d5796a 100644 --- a/api/src/channel/channel.controller.ts +++ b/api/src/channel/channel.controller.ts @@ -23,7 +23,7 @@ export class ChannelController { getChannels(): { name: string }[] { return this.channelService.getAll().map((handler) => { return { - name: handler.getChannel(), + name: handler.getName(), }; }); } diff --git a/api/src/channel/channel.middleware.ts b/api/src/channel/channel.middleware.ts index fd6949870..f5a497967 100644 --- a/api/src/channel/channel.middleware.ts +++ b/api/src/channel/channel.middleware.ts @@ -7,7 +7,7 @@ */ import { Injectable, NestMiddleware } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; +import { NextFunction, Request, Response } from 'express'; import { ChannelService } from './channel.service'; @@ -20,7 +20,9 @@ export class ChannelMiddleware implements NestMiddleware { try { const [_, path, channelName] = req.path.split('/'); if (path === 'webhook' && channelName) { - const channel = this.channelService.getChannelHandler(channelName); + const channel = this.channelService.getChannelHandler( + `${channelName}-channel`, + ); if (channel) { return await channel.middleware(req, res, next); } diff --git a/api/src/channel/channel.module.ts b/api/src/channel/channel.module.ts index c5732dcb3..3fae41a85 100644 --- a/api/src/channel/channel.module.ts +++ b/api/src/channel/channel.module.ts @@ -7,7 +7,12 @@ */ import { HttpModule } from '@nestjs/axios'; -import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { + Global, + MiddlewareConsumer, + Module, + RequestMethod, +} from '@nestjs/common'; import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; import { AttachmentModule } from '@/attachment/attachment.module'; @@ -23,7 +28,13 @@ export interface ChannelModuleOptions { folder: string; } -@InjectDynamicProviders('dist/extensions/**/*.channel.js') +@Global() +@InjectDynamicProviders( + // Core & under dev channels + 'dist/extensions/**/*.channel.js', + // Installed channels via npm + 'dist/.hexabot/channels/**/*.channel.js', +) @Module({ controllers: [WebhookController, ChannelController], providers: [ChannelService], diff --git a/api/src/channel/channel.service.ts b/api/src/channel/channel.service.ts index e87d0f3ff..4c2d9986a 100644 --- a/api/src/channel/channel.service.ts +++ b/api/src/channel/channel.service.ts @@ -10,8 +10,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Request, Response } from 'express'; import { SubscriberService } from '@/chat/services/subscriber.service'; -import { LIVE_CHAT_TEST_CHANNEL_NAME } from '@/extensions/channels/live-chat-tester/settings'; -import { OFFLINE_CHANNEL_NAME } from '@/extensions/channels/offline/settings'; +import { CONSOLE_CHANNEL_NAME } from '@/extensions/channels/console/settings'; +import { WEB_CHANNEL_NAME } from '@/extensions/channels/web/settings'; import { LoggerService } from '@/logger/logger.service'; import { SocketGet, @@ -23,10 +23,11 @@ import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; import ChannelHandler from './lib/Handler'; +import { ChannelName } from './types'; @Injectable() export class ChannelService { - private registry: Map> = new Map(); + private registry: Map> = new Map(); constructor( private readonly logger: LoggerService, @@ -40,7 +41,7 @@ export class ChannelService { * @param channel - The channel handler associated with the channel name. * @typeParam C The channel handler's type that extends `ChannelHandler`. */ - public setChannel>( + public setChannel>( name: T, channel: C, ) { @@ -62,19 +63,19 @@ export class ChannelService { * @param name - The name of the channel to find. * @returns The channel handler associated with the specified name, or undefined if the channel is not found. */ - public findChannel(name: string) { + public findChannel(name: ChannelName) { return this.getAll().find((c) => { - return c.getChannel() === name; + return c.getName() === name; }); } /** * Retrieves the appropriate channel handler based on the channel name. * - * @param channelName - The name of the channel (messenger, offline, ...). + * @param channelName - The name of the channel (messenger, web, ...). * @returns The handler for the specified channel. */ - public getChannelHandler>( + public getChannelHandler>( name: T, ): C { const handler = this.registry.get(name); @@ -93,42 +94,42 @@ export class ChannelService { * @returns A promise that resolves when the handler has processed the request. */ async handle(channel: string, req: Request, res: Response): Promise { - const handler = this.getChannelHandler(channel); + const handler = this.getChannelHandler(`${channel}-channel`); handler.handle(req, res); } /** - * Handles a websocket request for the offline channel. + * Handles a websocket request for the web channel. * * @param req - The websocket request object. * @param res - The websocket response object. */ - @SocketGet(`/webhook/${OFFLINE_CHANNEL_NAME}/`) - @SocketPost(`/webhook/${OFFLINE_CHANNEL_NAME}/`) - handleWebsocketForOffline( + @SocketGet(`/webhook/${WEB_CHANNEL_NAME}/`) + @SocketPost(`/webhook/${WEB_CHANNEL_NAME}/`) + handleWebsocketForWebChannel( @SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse, ) { - this.logger.log('Channel notification (Offline Socket) : ', req.method); - const handler = this.getChannelHandler(OFFLINE_CHANNEL_NAME); + this.logger.log('Channel notification (Web Socket) : ', req.method); + const handler = this.getChannelHandler(WEB_CHANNEL_NAME); return handler.handle(req, res); } /** - * Handles a websocket request for the live chat tester channel. + * Handles a websocket request for the admin chat console channel. * It considers the user as a subscriber. * * @param req - The websocket request object. * @param res - The websocket response object. */ - @SocketGet(`/webhook/${LIVE_CHAT_TEST_CHANNEL_NAME}/`) - @SocketPost(`/webhook/${LIVE_CHAT_TEST_CHANNEL_NAME}/`) - async handleWebsocketForLiveChatTester( + @SocketGet(`/webhook/${CONSOLE_CHANNEL_NAME}/`) + @SocketPost(`/webhook/${CONSOLE_CHANNEL_NAME}/`) + async handleWebsocketForAdminChatConsole( @SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse, ) { this.logger.log( - 'Channel notification (Live Chat Tester Socket) : ', + 'Channel notification (Admin Chat Console Socket) : ', req.method, ); @@ -157,21 +158,21 @@ export class ChannelService { country: '', labels: [], channel: { - name: LIVE_CHAT_TEST_CHANNEL_NAME, + name: CONSOLE_CHANNEL_NAME, isSocket: true, }, }, ); // Update session (end user is both a user + subscriber) - req.session.offline = { + req.session.web = { profile: testSubscriber, isSocket: true, messageQueue: [], polling: false, }; - const handler = this.getChannelHandler(LIVE_CHAT_TEST_CHANNEL_NAME); + const handler = this.getChannelHandler(CONSOLE_CHANNEL_NAME); return handler.handle(req, res); } } diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index 0c7f8f235..9456e2007 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -55,7 +55,7 @@ export default abstract class EventWrapper< toString() { return JSON.stringify( { - handler: this._handler.getChannel(), + handler: this._handler.getName(), channelData: this.getChannelData(), sender: this.getSender(), recipient: this.getRecipientForeignId(), diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 44d13b7e4..4b265b23d 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -6,7 +6,9 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { Injectable } from '@nestjs/common'; +import path from 'path'; + +import { Injectable, OnModuleInit } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { Attachment } from '@/attachment/schemas/attachment.schema'; @@ -17,35 +19,39 @@ import { } from '@/chat/schemas/types/message'; import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; -import { hyphenToUnderscore } from '@/utils/helpers/misc'; +import { Extension } from '@/utils/generics/extension'; import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; import { ChannelService } from '../channel.service'; -import { ChannelSetting } from '../types'; +import { ChannelName, ChannelSetting } from '../types'; import EventWrapper from './EventWrapper'; @Injectable() -export default abstract class ChannelHandler { - private readonly name: N; - +export default abstract class ChannelHandler< + N extends ChannelName = ChannelName, + > + extends Extension + implements OnModuleInit +{ private readonly settings: ChannelSetting[]; constructor( name: N, - settings: ChannelSetting[], protected readonly settingService: SettingService, private readonly channelService: ChannelService, protected readonly logger: LoggerService, ) { - this.name = name; - this.settings = settings; + super(name); + // eslint-disable-next-line @typescript-eslint/no-var-requires + this.settings = require(path.join(this.getPath(), 'settings')).default; } - onModuleInit() { + async onModuleInit() { + await super.onModuleInit(); this.channelService.setChannel( - this.getChannel(), + this.getName() as ChannelName, this as unknown as ChannelHandler, ); this.setup(); @@ -53,7 +59,7 @@ export default abstract class ChannelHandler { async setup() { await this.settingService.seedIfNotExist( - this.getChannel(), + this.getName(), this.settings.map((s, i) => ({ ...s, weight: i + 1, @@ -62,22 +68,6 @@ export default abstract class ChannelHandler { this.init(); } - /** - * Returns the channel's name - * @returns Channel's name - */ - getChannel() { - return this.name; - } - - /** - * Returns the channel's group - * @returns Channel's group - */ - protected getGroup() { - return hyphenToUnderscore(this.getChannel()) as ChannelSetting['group']; - } - /** * Returns the channel's settings * @returns Channel's settings @@ -85,7 +75,7 @@ export default abstract class ChannelHandler { async getSettings>() { const settings = await this.settingService.getSettings(); // @ts-expect-error workaround typing - return settings[this.getGroup() as keyof Settings] as Settings[S]; + return settings[this.getNamespace() as keyof Settings] as Settings[S]; } /** diff --git a/api/src/channel/lib/__test__/label.mock.ts b/api/src/channel/lib/__test__/label.mock.ts index af706bc5e..a2a6082df 100644 --- a/api/src/channel/lib/__test__/label.mock.ts +++ b/api/src/channel/lib/__test__/label.mock.ts @@ -16,7 +16,7 @@ const baseLabel: Label = { name: '', label_id: { messenger: '', - offline: '', + web: '', dimelo: '', twitter: '', }, @@ -30,7 +30,7 @@ export const labelMock: Label = { name: 'label', label_id: { messenger: 'none', - offline: 'none', + web: 'none', dimelo: 'none', twitter: 'none', }, @@ -43,7 +43,7 @@ export const customerLabelsMock: Label[] = [ name: 'client', label_id: { messenger: 'none', - offline: 'none', + web: 'none', dimelo: 'none', twitter: 'none', }, @@ -54,7 +54,7 @@ export const customerLabelsMock: Label[] = [ name: 'profressional', label_id: { messenger: 'none', - offline: 'none', + web: 'none', dimelo: 'none', twitter: 'none', }, diff --git a/api/src/channel/lib/__test__/subscriber.mock.ts b/api/src/channel/lib/__test__/subscriber.mock.ts index 5b40ad159..5f2bda7e7 100644 --- a/api/src/channel/lib/__test__/subscriber.mock.ts +++ b/api/src/channel/lib/__test__/subscriber.mock.ts @@ -25,7 +25,7 @@ export const subscriberInstance: Subscriber = { lastvisit: new Date(), retainedFrom: new Date(), channel: { - name: 'offline', + name: 'web-channel', }, labels: [], ...modelInstance, diff --git a/api/src/channel/types.ts b/api/src/channel/types.ts index 4b9f3329a..c91743214 100644 --- a/api/src/channel/types.ts +++ b/api/src/channel/types.ts @@ -1,5 +1,15 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + import { SettingCreateDto } from '@/setting/dto/setting.dto'; +export type ChannelName = `${string}-channel`; + export type ChannelSetting = Omit< SettingCreateDto, 'group' | 'weight' diff --git a/api/src/chat/controllers/block.controller.ts b/api/src/chat/controllers/block.controller.ts index e68e0788f..6621b6f6a 100644 --- a/api/src/chat/controllers/block.controller.ts +++ b/api/src/chat/controllers/block.controller.ts @@ -27,7 +27,7 @@ import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; import { BaseBlockPlugin } from '@/plugins/base-block-plugin'; import { PluginService } from '@/plugins/plugins.service'; -import { PluginType } from '@/plugins/types'; +import { PluginName, PluginType } from '@/plugins/types'; import { UserService } from '@/user/services/user.service'; import { BaseController } from '@/utils/generics/base-controller'; import { DeleteResult } from '@/utils/generics/base-repository'; @@ -85,20 +85,23 @@ export class BlockController extends BaseController< /** * Retrieves a custom block settings for a specific plugin. * - * @param pluginId - The name of the plugin for which settings are to be retrieved. + * @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') - findSettings(@Query('plugin') pluginId: string) { + findSettings(@Query('plugin') pluginName: PluginName) { try { - if (!pluginId) { + if (!pluginName) { throw new BadRequestException( - 'Plugin id must be supplied as a query param', + 'Plugin name must be supplied as a query param', ); } - const plugin = this.pluginsService.getPlugin(PluginType.block, pluginId); + const plugin = this.pluginsService.getPlugin( + PluginType.block, + pluginName, + ); if (!plugin) { throw new NotFoundException('Plugin Not Found'); @@ -122,11 +125,12 @@ export class BlockController extends BaseController< const plugins = this.pluginsService .getAllByType(PluginType.block) .map((p) => ({ - id: p.id, + id: p.getName(), + namespace: p.getNamespace(), template: { ...p.template, message: { - plugin: p.id, + plugin: p.name, args: p.settings.reduce( (acc, setting) => { acc[setting.label] = setting.value; diff --git a/api/src/chat/controllers/label.controller.spec.ts b/api/src/chat/controllers/label.controller.spec.ts index f09ca8477..7eb51ae03 100644 --- a/api/src/chat/controllers/label.controller.spec.ts +++ b/api/src/chat/controllers/label.controller.spec.ts @@ -173,7 +173,7 @@ describe('LabelController', () => { name: 'LABEL_2', label_id: { messenger: 'messenger', - offline: 'offline', + web: 'web', twitter: 'twitter', dimelo: 'dimelo', }, diff --git a/api/src/chat/schemas/types/channel.ts b/api/src/chat/schemas/types/channel.ts index 306fee5d8..38a690832 100644 --- a/api/src/chat/schemas/types/channel.ts +++ b/api/src/chat/schemas/types/channel.ts @@ -6,8 +6,10 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import { ChannelName } from '@/channel/types'; + interface BaseChannelData { - name: string; // channel name + name: ChannelName; // channel name isSocket?: boolean; type?: any; //TODO: type has to be checked } diff --git a/api/src/chat/schemas/types/context.ts b/api/src/chat/schemas/types/context.ts index 7fdc08e9a..cae1ccf66 100644 --- a/api/src/chat/schemas/types/context.ts +++ b/api/src/chat/schemas/types/context.ts @@ -6,6 +6,7 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import { ChannelName } from '@/channel/types'; import { Nlp } from '@/helper/types'; import { Subscriber } from '../subscriber.schema'; @@ -13,7 +14,7 @@ import { Subscriber } from '../subscriber.schema'; import { Payload } from './quick-reply'; export interface Context { - channel?: string; + channel?: ChannelName; text?: string; payload?: Payload | string; nlp?: Nlp.ParseEntities | null; diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 72fae56f3..066e6feec 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -24,10 +24,10 @@ import { ContentTypeModel } from '@/cms/schemas/content-type.schema'; import { Content, ContentModel } from '@/cms/schemas/content.schema'; import { ContentTypeService } from '@/cms/services/content-type.service'; import { ContentService } from '@/cms/services/content.service'; -import OfflineHandler from '@/extensions/channels/offline/index.channel'; -import { OFFLINE_CHANNEL_NAME } from '@/extensions/channels/offline/settings'; -import { Offline } from '@/extensions/channels/offline/types'; -import OfflineEventWrapper from '@/extensions/channels/offline/wrapper'; +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 { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; import { I18nService } from '@/i18n/services/i18n.service'; @@ -222,22 +222,22 @@ describe('BlockService', () => { describe('match', () => { const handlerMock = { - getChannel: jest.fn(() => OFFLINE_CHANNEL_NAME), - } as any as OfflineHandler; - const offlineEventGreeting = new OfflineEventWrapper( + getName: jest.fn(() => WEB_CHANNEL_NAME), + } as any as WebChannelHandler; + const webEventGreeting = new WebEventWrapper( handlerMock, { - type: Offline.IncomingMessageType.text, + type: Web.IncomingMessageType.text, data: { text: 'Hello', }, }, {}, ); - const offlineEventGetStarted = new OfflineEventWrapper( + const webEventGetStarted = new WebEventWrapper( handlerMock, { - type: Offline.IncomingMessageType.postback, + type: Web.IncomingMessageType.postback, data: { text: 'Get Started', payload: 'GET_STARTED', @@ -247,40 +247,37 @@ describe('BlockService', () => { ); it('should return undefined when no blocks are provided', async () => { - const result = await blockService.match([], offlineEventGreeting); + const result = await blockService.match([], webEventGreeting); expect(result).toBe(undefined); }); it('should return undefined for empty blocks', async () => { - const result = await blockService.match( - [blockEmpty], - offlineEventGreeting, - ); + const result = await blockService.match([blockEmpty], webEventGreeting); expect(result).toEqual(undefined); }); it('should return undefined for no matching labels', async () => { - offlineEventGreeting.setSender(subscriberWithoutLabels); - const result = await blockService.match(blocks, offlineEventGreeting); + webEventGreeting.setSender(subscriberWithoutLabels); + const result = await blockService.match(blocks, webEventGreeting); expect(result).toEqual(undefined); }); it('should match block text and labels', async () => { - offlineEventGreeting.setSender(subscriberWithLabels); - const result = await blockService.match(blocks, offlineEventGreeting); + webEventGreeting.setSender(subscriberWithLabels); + const result = await blockService.match(blocks, webEventGreeting); expect(result).toEqual(blockGetStarted); }); it('should match block with payload', async () => { - offlineEventGetStarted.setSender(subscriberWithLabels); - const result = await blockService.match(blocks, offlineEventGetStarted); + webEventGetStarted.setSender(subscriberWithLabels); + const result = await blockService.match(blocks, webEventGetStarted); expect(result).toEqual(blockGetStarted); }); it('should match block with nlp', async () => { - offlineEventGreeting.setSender(subscriberWithLabels); - offlineEventGreeting.setNLP(nlpEntitiesGreeting); - const result = await blockService.match(blocks, offlineEventGreeting); + webEventGreeting.setSender(subscriberWithLabels); + webEventGreeting.setNLP(nlpEntitiesGreeting); + const result = await blockService.match(blocks, webEventGreeting); expect(result).toEqual(blockGetStarted); }); }); @@ -502,7 +499,7 @@ describe('BlockService', () => { describe('processText', () => { const context: Context = { ...contextGetStartedInstance, - channel: 'offline', + channel: 'web-channel', text: '', payload: undefined, nlp: { entities: [] }, diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 73ffa57a9..f7353b73a 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -17,7 +17,7 @@ import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; import { LoggerService } from '@/logger/logger.service'; import { PluginService } from '@/plugins/plugins.service'; -import { PluginType } from '@/plugins/types'; +import { PluginName, PluginType } from '@/plugins/types'; import { SettingService } from '@/setting/services/setting.service'; import { BaseService } from '@/utils/generics/base-service'; import { getRandom } from '@/utils/helpers/safeRandom'; @@ -71,7 +71,7 @@ export class BlockService extends BaseService { const payload = event.getPayload(); // Perform a filter on the specific channels - const channel = event.getHandler().getChannel(); + const channel = event.getHandler().getName(); blocks = blocks.filter((b) => { return ( !b.trigger_channels || @@ -593,7 +593,7 @@ export class BlockService extends BaseService { } else if (blockMessage && 'plugin' in blockMessage) { const plugin = this.pluginService.findPlugin( PluginType.block, - blockMessage.plugin, + blockMessage.plugin as PluginName, ); // Process custom plugin block try { diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 3134452d4..d2acfc85c 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -24,9 +24,9 @@ import { MenuModel } from '@/cms/schemas/menu.schema'; import { ContentTypeService } from '@/cms/services/content-type.service'; import { ContentService } from '@/cms/services/content.service'; import { MenuService } from '@/cms/services/menu.service'; -import { offlineEventText } from '@/extensions/channels/offline/__test__/events.mock'; -import OfflineHandler from '@/extensions/channels/offline/index.channel'; -import OfflineEventWrapper from '@/extensions/channels/offline/wrapper'; +import { webEventText } from '@/extensions/channels/web/__test__/events.mock'; +import WebChannelHandler from '@/extensions/channels/web/index.channel'; +import WebEventWrapper from '@/extensions/channels/web/wrapper'; import { HelperService } from '@/helper/helper.service'; import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; @@ -75,7 +75,7 @@ describe('BlockService', () => { let blockService: BlockService; let subscriberService: SubscriberService; let botService: BotService; - let handler: OfflineHandler; + let handler: WebChannelHandler; let eventEmitter: EventEmitter2; beforeAll(async () => { @@ -126,7 +126,7 @@ describe('BlockService', () => { ChannelService, MessageService, MenuService, - OfflineHandler, + WebChannelHandler, ContextVarService, ContextVarRepository, LanguageService, @@ -170,7 +170,7 @@ describe('BlockService', () => { botService = module.get(BotService); blockService = module.get(BlockService); eventEmitter = module.get(EventEmitter2); - handler = module.get(OfflineHandler); + handler = module.get(WebChannelHandler); }); afterEach(jest.clearAllMocks); @@ -183,38 +183,38 @@ describe('BlockService', () => { triggeredEvents.push(args); }); - const event = new OfflineEventWrapper(handler, offlineEventText, { + const event = new WebEventWrapper(handler, webEventText, { isSocket: false, ipAddress: '1.1.1.1', }); const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] }); - const offlineSubscriber = await subscriberService.findOne({ - foreign_id: 'foreign-id-offline-1', + const webSubscriber = await subscriberService.findOne({ + foreign_id: 'foreign-id-web-1', }); - event.setSender(offlineSubscriber); + event.setSender(webSubscriber); let hasBotSpoken = false; const clearMock = jest .spyOn(botService, 'findBlockAndSendReply') .mockImplementation( ( - actualEvent: OfflineEventWrapper, + actualEvent: WebEventWrapper, actualConversation: Conversation, actualBlock: BlockFull, isFallback: boolean, ) => { expect(actualConversation).toEqualPayload({ - sender: offlineSubscriber.id, + sender: webSubscriber.id, active: true, next: [], context: { user: { - first_name: offlineSubscriber.first_name, - last_name: offlineSubscriber.last_name, + first_name: webSubscriber.first_name, + last_name: webSubscriber.last_name, language: 'en', - id: offlineSubscriber.id, + id: webSubscriber.id, }, user_location: { lat: 0, @@ -224,8 +224,8 @@ describe('BlockService', () => { nlp: null, payload: null, attempt: 0, - channel: 'offline', - text: offlineEventText.data.text, + channel: 'web-channel', + text: webEventText.data.text, }, }); expect(actualEvent).toEqual(event); @@ -251,40 +251,40 @@ describe('BlockService', () => { triggeredEvents.push(args); }); - const event = new OfflineEventWrapper(handler, offlineEventText, { + const event = new WebEventWrapper(handler, webEventText, { isSocket: false, ipAddress: '1.1.1.1', }); - const offlineSubscriber = await subscriberService.findOne({ - foreign_id: 'foreign-id-offline-1', + const webSubscriber = await subscriberService.findOne({ + foreign_id: 'foreign-id-web-1', }); - event.setSender(offlineSubscriber); + event.setSender(webSubscriber); const clearMock = jest .spyOn(botService, 'handleIncomingMessage') .mockImplementation( async ( actualConversation: ConversationFull, - event: OfflineEventWrapper, + event: WebEventWrapper, ) => { expect(actualConversation).toEqualPayload({ next: [], - sender: offlineSubscriber, + sender: webSubscriber, active: true, context: { user: { - first_name: offlineSubscriber.first_name, - last_name: offlineSubscriber.last_name, + first_name: webSubscriber.first_name, + last_name: webSubscriber.last_name, language: 'en', - id: offlineSubscriber.id, + id: webSubscriber.id, }, user_location: { lat: 0, lon: 0 }, vars: {}, nlp: null, payload: null, attempt: 0, - channel: 'offline', - text: offlineEventText.data.text, + channel: 'web-channel', + text: webEventText.data.text, }, }); expect(event).toEqual(event); @@ -304,14 +304,14 @@ describe('BlockService', () => { eventEmitter.on('hook:stats:entry', (...args) => { triggeredEvents.push(args); }); - const event = new OfflineEventWrapper(handler, offlineEventText, { + const event = new WebEventWrapper(handler, webEventText, { isSocket: false, ipAddress: '1.1.1.1', }); - const offlineSubscriber = await subscriberService.findOne({ - foreign_id: 'foreign-id-offline-2', + const webSubscriber = await subscriberService.findOne({ + foreign_id: 'foreign-id-web-2', }); - event.setSender(offlineSubscriber); + event.setSender(webSubscriber); const captured = await botService.processConversationMessage(event); expect(captured).toBe(false); diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 5fdc2d67d..6342127b7 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -246,7 +246,7 @@ export class ChatService { this.eventEmitter.emit('hook:stats:entry', 'new_users', 'New users'); subscriberData.channel = { ...event.getChannelData(), - name: handler.getChannel(), + name: handler.getName(), }; subscriber = await this.subscriberService.create(subscriberData); } else { diff --git a/api/src/chat/services/conversation.service.ts b/api/src/chat/services/conversation.service.ts index b6ebb14e6..7845427d9 100644 --- a/api/src/chat/services/conversation.service.ts +++ b/api/src/chat/services/conversation.service.ts @@ -9,6 +9,7 @@ import { Injectable, Logger } from '@nestjs/common'; import EventWrapper from '@/channel/lib/EventWrapper'; +import { ChannelName } from '@/channel/types'; import { LoggerService } from '@/logger/logger.service'; import { BaseService } from '@/utils/generics/base-service'; @@ -69,7 +70,7 @@ export class ConversationService extends BaseService< const msgType = event.getMessageType(); const profile = event.getSender(); // Capture channel specific context data - convo.context.channel = event.getHandler().getChannel(); + convo.context.channel = event.getHandler().getName() as ChannelName; convo.context.text = event.getText(); convo.context.payload = event.getPayload(); convo.context.nlp = event.getNLP(); diff --git a/api/src/extensions/channels/live-chat-tester/i18n/en/label.json b/api/src/extensions/channels/console/i18n/en/label.json similarity index 100% rename from api/src/extensions/channels/live-chat-tester/i18n/en/label.json rename to api/src/extensions/channels/console/i18n/en/label.json diff --git a/api/src/extensions/channels/console/i18n/en/title.json b/api/src/extensions/channels/console/i18n/en/title.json new file mode 100644 index 000000000..d4c6be17c --- /dev/null +++ b/api/src/extensions/channels/console/i18n/en/title.json @@ -0,0 +1,3 @@ +{ + "console_channel": "Admin Chat Console" +} diff --git a/api/src/extensions/channels/live-chat-tester/i18n/fr/label.json b/api/src/extensions/channels/console/i18n/fr/label.json similarity index 100% rename from api/src/extensions/channels/live-chat-tester/i18n/fr/label.json rename to api/src/extensions/channels/console/i18n/fr/label.json diff --git a/api/src/extensions/channels/console/i18n/fr/title.json b/api/src/extensions/channels/console/i18n/fr/title.json new file mode 100644 index 000000000..2f22cb98e --- /dev/null +++ b/api/src/extensions/channels/console/i18n/fr/title.json @@ -0,0 +1,3 @@ +{ + "console_channel": "Testeur Live Chat" +} diff --git a/api/src/extensions/channels/live-chat-tester/index.channel.ts b/api/src/extensions/channels/console/index.channel.ts similarity index 84% rename from api/src/extensions/channels/live-chat-tester/index.channel.ts rename to api/src/extensions/channels/console/index.channel.ts index 0acddbac4..6dca5c3d5 100644 --- a/api/src/extensions/channels/live-chat-tester/index.channel.ts +++ b/api/src/extensions/channels/console/index.channel.ts @@ -19,16 +19,13 @@ import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; -import BaseWebChannelHandler from '../offline/base-web-channel'; +import BaseWebChannelHandler from '../web/base-web-channel'; -import { - DEFAULT_LIVE_CHAT_TEST_SETTINGS, - LIVE_CHAT_TEST_CHANNEL_NAME, -} from './settings'; +import { CONSOLE_CHANNEL_NAME } from './settings'; @Injectable() -export default class LiveChatTesterHandler extends BaseWebChannelHandler< - typeof LIVE_CHAT_TEST_CHANNEL_NAME +export default class ConsoleChannelHandler extends BaseWebChannelHandler< + typeof CONSOLE_CHANNEL_NAME > { constructor( settingService: SettingService, @@ -43,8 +40,7 @@ export default class LiveChatTesterHandler extends BaseWebChannelHandler< websocketGateway: WebsocketGateway, ) { super( - LIVE_CHAT_TEST_CHANNEL_NAME, - DEFAULT_LIVE_CHAT_TEST_SETTINGS, + CONSOLE_CHANNEL_NAME, settingService, channelService, logger, @@ -57,4 +53,8 @@ export default class LiveChatTesterHandler extends BaseWebChannelHandler< websocketGateway, ); } + + getPath(): string { + return __dirname; + } } diff --git a/api/src/extensions/channels/console/index.d.ts b/api/src/extensions/channels/console/index.d.ts new file mode 100644 index 000000000..e7977a404 --- /dev/null +++ b/api/src/extensions/channels/console/index.d.ts @@ -0,0 +1,24 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import CONSOLE_CHANNEL_SETTINGS, { + CONSOLE_CHANNEL_NAMESPACE, +} from './settings'; + +declare global { + interface Settings extends SettingTree {} +} + +declare module '@nestjs/event-emitter' { + interface IHookExtensionsOperationMap { + [CONSOLE_CHANNEL_NAMESPACE]: TDefinition< + object, + SettingMapByType + >; + } +} diff --git a/api/src/extensions/channels/console/package.json b/api/src/extensions/channels/console/package.json new file mode 100644 index 000000000..7de9aa685 --- /dev/null +++ b/api/src/extensions/channels/console/package.json @@ -0,0 +1,7 @@ +{ + "name": "hexabot-channel-console", + "version": "2.0.0", + "description": "The Admin Chat Console Channel Extension for Hexabot Chatbot / Agent Builder.", + "author": "Hexastack", + "license": "AGPL-3.0-only" +} \ No newline at end of file diff --git a/api/src/extensions/channels/live-chat-tester/settings.ts b/api/src/extensions/channels/console/settings.ts similarity index 63% rename from api/src/extensions/channels/live-chat-tester/settings.ts rename to api/src/extensions/channels/console/settings.ts index e8afc9aef..0f7102a9e 100644 --- a/api/src/extensions/channels/live-chat-tester/settings.ts +++ b/api/src/extensions/channels/console/settings.ts @@ -10,85 +10,85 @@ import { ChannelSetting } from '@/channel/types'; import { config } from '@/config'; import { SettingType } from '@/setting/schemas/types'; -import { Offline } from '../offline/types'; +import { Web } from '../web/types'; -export const LIVE_CHAT_TEST_CHANNEL_NAME = 'live-chat-tester'; +export const CONSOLE_CHANNEL_NAME = 'console-channel'; -export const LIVE_CHAT_TEST_GROUP_NAME = 'live_chat_tester'; +export const CONSOLE_CHANNEL_NAMESPACE = 'console_channel'; -export const DEFAULT_LIVE_CHAT_TEST_SETTINGS = [ +export default [ { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.verification_token, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.verification_token, value: 'test', type: SettingType.text, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.allowed_domains, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.allowed_domains, value: config.frontendPath, type: SettingType.text, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.start_button, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.start_button, value: true, type: SettingType.checkbox, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.input_disabled, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.input_disabled, value: false, type: SettingType.checkbox, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.persistent_menu, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.persistent_menu, value: true, type: SettingType.checkbox, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.greeting_message, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.greeting_message, value: 'Welcome! Ready to start a conversation with our chatbot?', type: SettingType.textarea, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.theme_color, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.theme_color, value: 'teal', type: SettingType.select, options: ['teal', 'orange', 'red', 'green', 'blue', 'dark'], }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.show_emoji, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.show_emoji, value: true, type: SettingType.checkbox, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.show_file, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.show_file, value: true, type: SettingType.checkbox, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.show_location, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.show_location, value: true, type: SettingType.checkbox, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.allowed_upload_size, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.allowed_upload_size, value: 2500000, type: SettingType.number, }, { - group: LIVE_CHAT_TEST_GROUP_NAME, - label: Offline.SettingLabel.allowed_upload_types, + group: CONSOLE_CHANNEL_NAMESPACE, + label: Web.SettingLabel.allowed_upload_types, value: 'audio/mpeg,audio/x-ms-wma,audio/vnd.rn-realaudio,audio/x-wav,image/gif,image/jpeg,image/png,image/tiff,image/vnd.microsoft.icon,image/vnd.djvu,image/svg+xml,text/css,text/csv,text/html,text/plain,text/xml,video/mpeg,video/mp4,video/quicktime,video/x-ms-wmv,video/x-msvideo,video/x-flv,video/web,application/msword,application/vnd.ms-powerpoint,application/pdf,application/vnd.ms-excel,application/vnd.oasis.opendocument.presentation,application/vnd.oasis.opendocument.tex,application/vnd.oasis.opendocument.spreadsheet,application/vnd.oasis.opendocument.graphics,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document', type: SettingType.textarea, }, -] as const satisfies ChannelSetting[]; +] as const satisfies ChannelSetting[]; diff --git a/api/src/extensions/channels/live-chat-tester/i18n/en/title.json b/api/src/extensions/channels/live-chat-tester/i18n/en/title.json deleted file mode 100644 index 67ff42267..000000000 --- a/api/src/extensions/channels/live-chat-tester/i18n/en/title.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "live_chat_tester": "Live Chat Tester" -} diff --git a/api/src/extensions/channels/live-chat-tester/i18n/fr/title.json b/api/src/extensions/channels/live-chat-tester/i18n/fr/title.json deleted file mode 100644 index 6671fd6a5..000000000 --- a/api/src/extensions/channels/live-chat-tester/i18n/fr/title.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "live_chat_tester": "Testeur Live Chat" -} diff --git a/api/src/extensions/channels/live-chat-tester/index.d.ts b/api/src/extensions/channels/live-chat-tester/index.d.ts deleted file mode 100644 index 27efb2d0f..000000000 --- a/api/src/extensions/channels/live-chat-tester/index.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - DEFAULT_LIVE_CHAT_TEST_SETTINGS, - LIVE_CHAT_TEST_GROUP_NAME, -} from './settings'; - -declare global { - interface Settings - extends SettingTree {} -} - -declare module '@nestjs/event-emitter' { - interface IHookExtensionsOperationMap { - [LIVE_CHAT_TEST_GROUP_NAME]: TDefinition< - object, - SettingMapByType - >; - } -} diff --git a/api/src/extensions/channels/offline/i18n/en/title.json b/api/src/extensions/channels/offline/i18n/en/title.json deleted file mode 100644 index 74d381c7c..000000000 --- a/api/src/extensions/channels/offline/i18n/en/title.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "offline": "Canal Web" -} diff --git a/api/src/extensions/channels/offline/i18n/fr/title.json b/api/src/extensions/channels/offline/i18n/fr/title.json deleted file mode 100644 index 6671fd6a5..000000000 --- a/api/src/extensions/channels/offline/i18n/fr/title.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "live_chat_tester": "Testeur Live Chat" -} diff --git a/api/src/extensions/channels/offline/index.d.ts b/api/src/extensions/channels/offline/index.d.ts deleted file mode 100644 index 39a1b26ad..000000000 --- a/api/src/extensions/channels/offline/index.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DEFAULT_OFFLINE_SETTINGS, OFFLINE_GROUP_NAME } from './settings'; - -declare global { - interface Settings extends SettingTree {} -} - -declare module '@nestjs/event-emitter' { - interface IHookExtensionsOperationMap { - [OFFLINE_GROUP_NAME]: TDefinition< - object, - SettingMapByType - >; - } -} diff --git a/api/src/extensions/channels/offline/__test__/data.mock.ts b/api/src/extensions/channels/web/__test__/data.mock.ts similarity index 83% rename from api/src/extensions/channels/offline/__test__/data.mock.ts rename to api/src/extensions/channels/web/__test__/data.mock.ts index c136112f7..ae5fcd29d 100644 --- a/api/src/extensions/channels/offline/__test__/data.mock.ts +++ b/api/src/extensions/channels/web/__test__/data.mock.ts @@ -12,14 +12,14 @@ import { ButtonType } from '@/chat/schemas/types/button'; import { FileType } from '@/chat/schemas/types/message'; import { QuickReplyType } from '@/chat/schemas/types/quick-reply'; -import { Offline } from '../types'; +import { Web } from '../types'; -export const offlineText: Offline.OutgoingMessageBase = { - type: Offline.OutgoingMessageType.text, +export const webText: Web.OutgoingMessageBase = { + type: Web.OutgoingMessageType.text, data: textMessage, }; -export const offlineQuickReplies: Offline.OutgoingMessageBase = { +export const webQuickReplies: Web.OutgoingMessageBase = { data: { quick_replies: [ { @@ -35,10 +35,10 @@ export const offlineQuickReplies: Offline.OutgoingMessageBase = { ], text: 'Choose one option', }, - type: Offline.OutgoingMessageType.quick_replies, + type: Web.OutgoingMessageType.quick_replies, }; -export const offlineButtons: Offline.OutgoingMessageBase = { +export const webButtons: Web.OutgoingMessageBase = { data: { buttons: [ { @@ -56,10 +56,10 @@ export const offlineButtons: Offline.OutgoingMessageBase = { ], text: 'Hit one of these buttons :', }, - type: Offline.OutgoingMessageType.buttons, + type: Web.OutgoingMessageType.buttons, }; -export const offlineList: Offline.OutgoingMessageBase = { +export const webList: Web.OutgoingMessageBase = { data: { buttons: [ { @@ -95,10 +95,10 @@ export const offlineList: Offline.OutgoingMessageBase = { }, ], }, - type: Offline.OutgoingMessageType.list, + type: Web.OutgoingMessageType.list, }; -export const offlineCarousel: Offline.OutgoingMessageBase = { +export const webCarousel: Web.OutgoingMessageBase = { data: { elements: [ { @@ -127,10 +127,10 @@ export const offlineCarousel: Offline.OutgoingMessageBase = { }, ], }, - type: Offline.OutgoingMessageType.carousel, + type: Web.OutgoingMessageType.carousel, }; -export const offlineAttachment: Offline.OutgoingMessageBase = { +export const webAttachment: Web.OutgoingMessageBase = { data: { quick_replies: [ { @@ -142,5 +142,5 @@ export const offlineAttachment: Offline.OutgoingMessageBase = { type: FileType.image, url: 'http://localhost:4000/attachment/download/1/attachment.jpg', }, - type: Offline.OutgoingMessageType.file, + type: Web.OutgoingMessageType.file, }; diff --git a/api/src/extensions/channels/offline/__test__/events.mock.ts b/api/src/extensions/channels/web/__test__/events.mock.ts similarity index 60% rename from api/src/extensions/channels/offline/__test__/events.mock.ts rename to api/src/extensions/channels/web/__test__/events.mock.ts index 4ce0db10c..334482be1 100644 --- a/api/src/extensions/channels/offline/__test__/events.mock.ts +++ b/api/src/extensions/channels/web/__test__/events.mock.ts @@ -12,56 +12,55 @@ import { StdEventType, } from '@/chat/schemas/types/message'; -import { Offline } from '../types'; +import { Web } from '../types'; const img_url = 'http://demo.hexabot.ai/attachment/download/5c334078e2c41d11206bd152/myimage.png'; -// Offline events -const offlineEventPayload: Offline.Event = { - type: Offline.IncomingMessageType.postback, +// Web events +const webEventPayload: Web.Event = { + type: Web.IncomingMessageType.postback, data: { text: 'Get Started', payload: 'GET_STARTED', }, - author: 'offline-9be7aq09-b45a-452q-bcs0-f145b9qce1cad', - mid: 'offline-event-payload', + author: 'web-9be7aq09-b45a-452q-bcs0-f145b9qce1cad', + mid: 'web-event-payload', read: true, }; -export const offlineEventText: Offline.IncomingMessage = - { - type: Offline.IncomingMessageType.text, - data: { - text: 'Hello', - }, - author: 'offline-9qsdfgqxac09-f83a-452d-bca0-f1qsdqg457c1ad', - mid: 'offline-event-text', - read: true, - }; +export const webEventText: Web.IncomingMessage = { + type: Web.IncomingMessageType.text, + data: { + text: 'Hello', + }, + author: 'web-9qsdfgqxac09-f83a-452d-bca0-f1qsdqg457c1ad', + mid: 'web-event-text', + read: true, +}; -const offlineEventLocation: Offline.IncomingMessage = { - type: Offline.IncomingMessageType.location, +const webEventLocation: Web.IncomingMessage = { + type: Web.IncomingMessageType.location, data: { coordinates: { lat: 2.0545, lng: 12.2558, }, }, - author: 'offline-9beqsdqa09-b489a-438c-bqd0-f11buykkhl851ad', - mid: 'offline-event-location', + author: 'web-9beqsdqa09-b489a-438c-bqd0-f11buykkhl851ad', + mid: 'web-event-location', read: true, }; -const offlineEventFile: Offline.Event = { - type: Offline.IncomingMessageType.file, +const webEventFile: Web.Event = { + type: Web.IncomingMessageType.file, data: { type: FileType.image, url: img_url, size: 500, }, - author: 'offline-9be8ac09-b43a-432d-bca0-f11b98cec1ad', - mid: 'offline-event-file', + author: 'web-9be8ac09-b43a-432d-bca0-f11b98cec1ad', + mid: 'web-event-file', read: true, }; @@ -85,66 +84,66 @@ const fileChannelData = { ipAddress: '3.3.3.3', }; -export const offlineEvents: [string, Offline.IncomingMessage, any][] = [ +export const webEvents: [string, Web.IncomingMessage, any][] = [ [ 'Payload Event', - offlineEventPayload, + webEventPayload, { channelData: payloadChannelData, - id: offlineEventPayload.mid, + id: webEventPayload.mid, eventType: StdEventType.message, messageType: IncomingMessageType.postback, - payload: offlineEventPayload.data.payload, + payload: webEventPayload.data.payload, message: { - postback: offlineEventPayload.data.payload, - text: offlineEventPayload.data.text, + postback: webEventPayload.data.payload, + text: webEventPayload.data.text, }, }, ], [ 'Text Event', - offlineEventText, + webEventText, { channelData: textChannelData, - id: offlineEventText.mid, + id: webEventText.mid, eventType: StdEventType.message, messageType: IncomingMessageType.message, payload: undefined, message: { - text: offlineEventText.data.text, + text: webEventText.data.text, }, }, ], [ 'Location Event', - offlineEventLocation, + webEventLocation, { channelData: locationChannelData, - id: offlineEventLocation.mid, + id: webEventLocation.mid, eventType: StdEventType.message, messageType: IncomingMessageType.location, payload: { - type: Offline.IncomingMessageType.location, + type: Web.IncomingMessageType.location, coordinates: { - lat: offlineEventLocation.data.coordinates.lat, - lon: offlineEventLocation.data.coordinates.lng, + lat: webEventLocation.data.coordinates.lat, + lon: webEventLocation.data.coordinates.lng, }, }, message: { - type: Offline.IncomingMessageType.location, + type: Web.IncomingMessageType.location, coordinates: { - lat: offlineEventLocation.data.coordinates.lat, - lon: offlineEventLocation.data.coordinates.lng, + lat: webEventLocation.data.coordinates.lat, + lon: webEventLocation.data.coordinates.lng, }, }, }, ], [ 'File Event', - offlineEventFile, + webEventFile, { channelData: fileChannelData, - id: offlineEventFile.mid, + id: webEventFile.mid, eventType: StdEventType.message, messageType: IncomingMessageType.attachments, payload: { diff --git a/api/src/extensions/channels/offline/__test__/index.spec.ts b/api/src/extensions/channels/web/__test__/index.spec.ts similarity index 89% rename from api/src/extensions/channels/offline/__test__/index.spec.ts rename to api/src/extensions/channels/web/__test__/index.spec.ts index b93b16cf8..4ad78dbef 100644 --- a/api/src/extensions/channels/offline/__test__/index.spec.ts +++ b/api/src/extensions/channels/web/__test__/index.spec.ts @@ -48,21 +48,21 @@ import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; -import OfflineHandler from '../index.channel'; +import WebChannelHandler from '../index.channel'; import { - offlineAttachment, - offlineButtons, - offlineCarousel, - offlineList, - offlineQuickReplies, - offlineText, + webAttachment, + webButtons, + webCarousel, + webList, + webQuickReplies, + webText, } from './data.mock'; -describe('Offline Handler', () => { +describe('WebChannelHandler', () => { let subscriberService: SubscriberService; - let handler: OfflineHandler; - const offlineSettings = {}; + let handler: WebChannelHandler; + const webSettings = {}; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -87,7 +87,7 @@ describe('Offline Handler', () => { chatbot: { lang: { default: 'fr' } }, })), getSettings: jest.fn(() => ({ - offline: offlineSettings, + web: webSettings, })), }, }, @@ -102,7 +102,7 @@ describe('Offline Handler', () => { MessageRepository, MenuService, MenuRepository, - OfflineHandler, + WebChannelHandler, EventEmitter2, LoggerService, { @@ -122,7 +122,7 @@ describe('Offline Handler', () => { ], }).compile(); subscriberService = module.get(SubscriberService); - handler = module.get(OfflineHandler); + handler = module.get(WebChannelHandler); }); afterAll(async () => { @@ -131,29 +131,29 @@ describe('Offline Handler', () => { it('should have correct name', () => { expect(handler).toBeDefined(); - expect(handler.getChannel()).toEqual('offline'); + expect(handler.getName()).toEqual('web-channel'); }); it('should format text properly', () => { const formatted = handler._textFormat(textMessage, {}); - expect(formatted).toEqual(offlineText); + expect(formatted).toEqual(webText); }); it('should format quick replies properly', () => { const formatted = handler._quickRepliesFormat(quickRepliesMessage, {}); - expect(formatted).toEqual(offlineQuickReplies); + expect(formatted).toEqual(webQuickReplies); }); it('should format buttons properly', () => { const formatted = handler._buttonsFormat(buttonsMessage, {}); - expect(formatted).toEqual(offlineButtons); + expect(formatted).toEqual(webButtons); }); it('should format list properly', () => { const formatted = handler._listFormat(contentMessage, { content: contentMessage.options, }); - expect(formatted).toEqual(offlineList); + expect(formatted).toEqual(webList); }); it('should format carousel properly', () => { @@ -163,12 +163,12 @@ describe('Offline Handler', () => { display: OutgoingMessageFormat.carousel, }, }); - expect(formatted).toEqual(offlineCarousel); + expect(formatted).toEqual(webCarousel); }); it('should format attachment properly', () => { const formatted = handler._attachmentFormat(attachmentMessage, {}); - expect(formatted).toEqual(offlineAttachment); + expect(formatted).toEqual(webAttachment); }); it('creates a new subscriber if needed + set a new session', async () => { @@ -180,7 +180,7 @@ describe('Offline Handler', () => { user: {}, } as any as Request; - const generatedId = 'offline-test'; + const generatedId = 'web-test'; const clearMock = jest .spyOn(handler, 'generateId') .mockImplementation(() => generatedId); @@ -192,7 +192,7 @@ describe('Offline Handler', () => { agent: req.headers['user-agent'], ipAddress: '0.0.0.0', isSocket: false, - name: 'offline', + name: 'web-channel', }, country: '', first_name: req.query.first_name, @@ -209,7 +209,7 @@ describe('Offline Handler', () => { }, {}); expect(subscriberAttrs).toEqual(expectedAttrs); expect(req.session).toEqual({ - offline: { + web: { isSocket: false, messageQueue: [], polling: false, @@ -222,7 +222,7 @@ describe('Offline Handler', () => { const subscriber2nd = await handler['getOrCreateSession'](req); expect(subscriber2nd.id).toBe(subscriber.id); expect(req.session).toEqual({ - offline: { + web: { isSocket: false, messageQueue: [], polling: false, @@ -232,9 +232,8 @@ describe('Offline Handler', () => { }); it('subscribes and returns the message history', async () => { - const subscriber = await subscriberService.findOneByForeignIdAndPopulate( - 'foreign-id-offline-1', - ); + const subscriber = + await subscriberService.findOneByForeignIdAndPopulate('foreign-id-web-1'); const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const req = { @@ -257,7 +256,7 @@ describe('Offline Handler', () => { } as any as SocketResponse; req.session = { cookie: { originalMaxAge: 0 }, - offline: { + web: { isSocket: true, messageQueue: [], polling: false, diff --git a/api/src/extensions/channels/offline/__test__/wrapper.spec.ts b/api/src/extensions/channels/web/__test__/wrapper.spec.ts similarity index 78% rename from api/src/extensions/channels/offline/__test__/wrapper.spec.ts rename to api/src/extensions/channels/web/__test__/wrapper.spec.ts index 4abfc6b22..c206a6c0b 100644 --- a/api/src/extensions/channels/offline/__test__/wrapper.spec.ts +++ b/api/src/extensions/channels/web/__test__/wrapper.spec.ts @@ -36,14 +36,14 @@ import { import { SocketEventDispatcherService } from '@/websocket/services/socket-event-dispatcher.service'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; -import OfflineHandler from '../index.channel'; -import OfflineEventWrapper from '../wrapper'; +import WebChannelHandler from '../index.channel'; +import WebEventWrapper from '../wrapper'; -import { offlineEvents } from './events.mock'; +import { webEvents } from './events.mock'; -describe(`Offline event wrapper`, () => { - let handler: OfflineHandler; - const offlineSettings = {}; +describe(`Web event wrapper`, () => { + let handler: WebChannelHandler; + const webSettings = {}; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -65,7 +65,7 @@ describe(`Offline event wrapper`, () => { chatbot: { lang: { default: 'fr' } }, })), getSettings: jest.fn(() => ({ - offline: offlineSettings, + web: webSettings, })), }, }, @@ -86,7 +86,7 @@ describe(`Offline event wrapper`, () => { MessageRepository, MenuService, MenuRepository, - OfflineHandler, + WebChannelHandler, EventEmitter2, LoggerService, { @@ -105,7 +105,7 @@ describe(`Offline event wrapper`, () => { }, ], }).compile(); - handler = module.get(OfflineHandler); + handler = module.get(WebChannelHandler); }); afterAll(async () => { @@ -113,21 +113,18 @@ describe(`Offline event wrapper`, () => { await closeInMongodConnection(); }); - test.each(offlineEvents)( - 'should wrap event : %s', - (_testCase, e, expected) => { - const event = new OfflineEventWrapper( - handler as unknown as OfflineHandler, - e, - expected.channelData, - ); - expect(event.getChannelData()).toEqual(expected.channelData); - expect(event.getId()).toEqual(expected.id); - expect(event.getEventType()).toEqual(expected.eventType); - expect(event.getMessageType()).toEqual(expected.messageType); - expect(event.getPayload()).toEqual(expected.payload); - expect(event.getMessage()).toEqual(expected.message); - expect(event.getDeliveredMessages()).toEqual([]); - }, - ); + test.each(webEvents)('should wrap event : %s', (_testCase, e, expected) => { + const event = new WebEventWrapper( + handler as unknown as WebChannelHandler, + e, + expected.channelData, + ); + expect(event.getChannelData()).toEqual(expected.channelData); + expect(event.getId()).toEqual(expected.id); + expect(event.getEventType()).toEqual(expected.eventType); + expect(event.getMessageType()).toEqual(expected.messageType); + expect(event.getPayload()).toEqual(expected.payload); + expect(event.getMessage()).toEqual(expected.message); + expect(event.getDeliveredMessages()).toEqual([]); + }); }); diff --git a/api/src/extensions/channels/offline/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts similarity index 89% rename from api/src/extensions/channels/offline/base-web-channel.ts rename to api/src/extensions/channels/web/base-web-channel.ts index 65d0c4d00..59e70f07b 100644 --- a/api/src/extensions/channels/offline/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -22,7 +22,7 @@ import { AttachmentService } from '@/attachment/services/attachment.service'; import { ChannelService } from '@/channel/channel.service'; import EventWrapper from '@/channel/lib/EventWrapper'; import ChannelHandler from '@/channel/lib/Handler'; -import { ChannelSetting } from '@/channel/types'; +import { ChannelName } from '@/channel/types'; import { MessageCreateDto } from '@/chat/dto/message.dto'; import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants'; @@ -58,17 +58,16 @@ import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; -import { OFFLINE_GROUP_NAME } from './settings'; -import { Offline } from './types'; -import OfflineEventWrapper from './wrapper'; +import { WEB_CHANNEL_NAMESPACE } from './settings'; +import { Web } from './types'; +import WebEventWrapper from './wrapper'; @Injectable() -export default class BaseWebChannelHandler< - N extends string, +export default abstract class BaseWebChannelHandler< + N extends ChannelName, > extends ChannelHandler { constructor( name: N, - settings: ChannelSetting[], settingService: SettingService, channelService: ChannelService, logger: LoggerService, @@ -80,7 +79,7 @@ export default class BaseWebChannelHandler< protected readonly menuService: MenuService, private readonly websocketGateway: WebsocketGateway, ) { - super(name, settings, settingService, channelService, logger); + super(name, settingService, channelService, logger); } /** @@ -93,7 +92,7 @@ export default class BaseWebChannelHandler< } /** - * Verify offline websocket connection and return settings + * Verify web websocket connection and return settings * * @param client - The socket client */ @@ -102,7 +101,7 @@ export default class BaseWebChannelHandler< const settings = await this.getSettings(); const handshake = client.handshake; const { channel } = handshake.query; - if (channel !== this.getChannel()) { + if (channel !== this.getName()) { return; } try { @@ -139,20 +138,20 @@ export default class BaseWebChannelHandler< } /** - * Adapt incoming message structure for offline channel + * Adapt incoming message structure for web channel * * @param incoming - Incoming message - * @returns Formatted offline message + * @returns Formatted web message */ private formatIncomingHistoryMessage( incoming: IncomingMessage, - ): Offline.IncomingMessageBase { + ): Web.IncomingMessageBase { // Format incoming message if ('type' in incoming.message) { if (incoming.message.type === PayloadType.location) { const coordinates = incoming.message.coordinates; return { - type: Offline.IncomingMessageType.location, + type: Web.IncomingMessageType.location, data: { coordinates: { lat: coordinates.lat, @@ -166,7 +165,7 @@ export default class BaseWebChannelHandler< ? incoming.message.attachment[0] : incoming.message.attachment; return { - type: Offline.IncomingMessageType.file, + type: Web.IncomingMessageType.file, data: { type: attachment.type, url: attachment.payload.url, @@ -175,21 +174,21 @@ export default class BaseWebChannelHandler< } } else { return { - type: Offline.IncomingMessageType.text, + type: Web.IncomingMessageType.text, data: incoming.message, }; } } /** - * Adapt the outgoing message structure for offline channel + * Adapt the outgoing message structure for web channel * * @param outgoing - The outgoing message - * @returns Formatted offline message + * @returns Formatted web message */ private formatOutgoingHistoryMessage( outgoing: OutgoingMessage, - ): Offline.OutgoingMessageBase { + ): Web.OutgoingMessageBase { // Format outgoing message if ('buttons' in outgoing.message) { return this._buttonsFormat(outgoing.message); @@ -213,13 +212,13 @@ export default class BaseWebChannelHandler< } /** - * Adapt the message structure for offline channel + * Adapt the message structure for web channel * * @param messages - The messages to be formatted * * @returns Formatted message */ - private formatHistoryMessages(messages: AnyMessage[]): Offline.Message[] { + private formatHistoryMessages(messages: AnyMessage[]): Web.Message[] { return messages.map((anyMessage: AnyMessage) => { if ('sender' in anyMessage && anyMessage.sender) { return { @@ -228,7 +227,7 @@ export default class BaseWebChannelHandler< read: true, // Temporary fix as read is false in the bd mid: anyMessage.mid, createdAt: anyMessage.createdAt, - } as Offline.IncomingMessage; + } as Web.IncomingMessage; } else { const outgoingMessage = anyMessage as OutgoingMessage; return { @@ -238,7 +237,7 @@ export default class BaseWebChannelHandler< mid: outgoingMessage.mid, handover: !!outgoingMessage.handover, createdAt: outgoingMessage.createdAt, - } as Offline.OutgoingMessage; + } as Web.OutgoingMessage; } }); } @@ -255,8 +254,8 @@ export default class BaseWebChannelHandler< req: Request | SocketRequest, until: Date = new Date(), n: number = 30, - ): Promise { - const profile = req.session?.offline?.profile; + ): Promise { + const profile = req.session?.web?.profile; if (profile) { const messages = await this.messageService.findHistoryUntilDate( profile, @@ -280,8 +279,8 @@ export default class BaseWebChannelHandler< req: Request, since: Date = new Date(10e14), n: number = 30, - ): Promise { - const profile = req.session?.offline?.profile; + ): Promise { + const profile = req.session?.web?.profile; if (profile) { const messages = await this.messageService.findHistorySinceDate( profile, @@ -300,7 +299,7 @@ export default class BaseWebChannelHandler< */ private async verifyToken(verificationToken: string) { const settings = - (await this.getSettings()) as Settings[typeof OFFLINE_GROUP_NAME]; + (await this.getSettings()) as Settings[typeof WEB_CHANNEL_NAMESPACE]; const verifyToken = settings.verification_token; if (!verifyToken) { @@ -327,7 +326,7 @@ export default class BaseWebChannelHandler< req: Request | SocketRequest, res: Response | SocketResponse, ) { - const settings = await this.getSettings(); + const settings = await this.getSettings(); // If we have an origin header... if (req.headers && req.headers.origin) { // Get the allowed origins @@ -377,7 +376,7 @@ export default class BaseWebChannelHandler< res: Response | SocketResponse, next: (profile: Subscriber) => void, ) { - if (!req.session?.offline?.profile?.id) { + if (!req.session?.web?.profile?.id) { this.logger.warn( 'Web Channel Handler : No session ID to be found!', req.session, @@ -386,8 +385,8 @@ export default class BaseWebChannelHandler< .status(403) .json({ err: 'Web Channel Handler : Unauthorized!' }); } else if ( - ('isSocket' in req && !!req.isSocket !== req.session.offline.isSocket) || - !Array.isArray(req.session.offline.messageQueue) + ('isSocket' in req && !!req.isSocket !== req.session.web.isSocket) || + !Array.isArray(req.session.web.messageQueue) ) { this.logger.warn( 'Web Channel Handler : Mixed channel request or invalid session data!', @@ -397,7 +396,7 @@ export default class BaseWebChannelHandler< .status(403) .json({ err: 'Web Channel Handler : Unauthorized!' }); } - next(req.session?.offline?.profile); + next(req.session?.web?.profile); } /** @@ -441,15 +440,15 @@ export default class BaseWebChannelHandler< ): Promise { const data = req.query; // Subscriber has already a session - const sessionProfile = req.session?.offline?.profile; + const sessionProfile = req.session?.web?.profile; if (sessionProfile) { const subscriber = await this.subscriberService.findOneAndPopulate( sessionProfile.id, ); - if (!subscriber || !req.session.offline) { + if (!subscriber || !req.session.web) { throw new Error('Subscriber session was not persisted in DB'); } - req.session.offline.profile = subscriber; + req.session.web.profile = subscriber; return subscriber; } @@ -457,14 +456,14 @@ export default class BaseWebChannelHandler< const newProfile: SubscriberCreateDto = { foreign_id: this.generateId(), first_name: data.first_name ? data.first_name.toString() : 'Anon.', - last_name: data.last_name ? data.last_name.toString() : 'Offline User', + last_name: data.last_name ? data.last_name.toString() : 'Web User', assignedTo: null, assignedAt: null, lastvisit: new Date(), retainedFrom: new Date(), channel: { ...channelData, - name: this.getChannel(), + name: this.getName() as ChannelName, }, language: '', locale: '', @@ -482,7 +481,7 @@ export default class BaseWebChannelHandler< avatar: null, }; - req.session.offline = { + req.session.web = { profile, isSocket: 'isSocket' in req && !!req.isSocket, messageQueue: [], @@ -508,9 +507,7 @@ export default class BaseWebChannelHandler< .json({ err: 'Polling not authorized when using websockets' }); } // Session must be active - if ( - !(req.session && req.session.offline && req.session.offline.profile.id) - ) { + if (!(req.session && req.session.web && req.session.web.profile.id)) { this.logger.warn( 'Web Channel Handler : Must be connected to poll messages', ); @@ -520,7 +517,7 @@ export default class BaseWebChannelHandler< } // Can only request polling once at a time - if (req.session && req.session.offline && req.session.offline.polling) { + if (req.session && req.session.web && req.session.web.polling) { this.logger.warn( 'Web Channel Handler : Poll rejected ... already requested', ); @@ -529,7 +526,7 @@ export default class BaseWebChannelHandler< .json({ err: 'Poll rejected ... already requested' }); } - req.session.offline.polling = true; + req.session.web.polling = true; const fetchMessages = async (req: Request, res: Response, retrials = 1) => { try { @@ -540,8 +537,8 @@ export default class BaseWebChannelHandler< setTimeout(async () => { await fetchMessages(req, res, retrials * 2); }, retrials * 1000); - } else if (req.session.offline) { - req.session.offline.polling = false; + } else if (req.session.web) { + req.session.web.polling = false; return res.status(200).json(messages.map((msg) => ['message', msg])); } else { this.logger.error( @@ -550,8 +547,8 @@ export default class BaseWebChannelHandler< return res.status(500).json({ err: 'No session data' }); } } catch (err) { - if (req.session.offline) { - req.session.offline.polling = false; + if (req.session.web) { + req.session.web.polling = false; } this.logger.error('Web Channel Handler : Polling failed', err); return res.status(500).json({ err: 'Polling failed' }); @@ -561,7 +558,7 @@ export default class BaseWebChannelHandler< } /** - * Allow the subscription to a offline's webhook after verification + * Allow the subscription to a web's webhook after verification * * @param req * @param res @@ -609,7 +606,7 @@ export default class BaseWebChannelHandler< * @param filename */ private async storeAttachment( - upload: Omit, + upload: Omit, filename: string, next: ( err: Error | null, @@ -624,7 +621,7 @@ export default class BaseWebChannelHandler< type: upload.type || 'text/txt', size: upload.size || 0, location: filename, - channel: { offline: {} }, + channel: { web: {} }, }); this.logger.debug( @@ -658,9 +655,9 @@ export default class BaseWebChannelHandler< result: { type: string; url: string } | false, ) => void, ): Promise { - const data: Offline.IncomingMessage = req.body; + const data: Web.IncomingMessage = req.body; // Check if any file is provided - if (!req.session.offline) { + if (!req.session.web) { this.logger.debug('Web Channel Handler : No session provided'); return next(null, false); } @@ -683,7 +680,7 @@ export default class BaseWebChannelHandler< // Store file as attachment const dirPath = path.join(config.parameters.uploadDir); const sanitizedFilename = sanitize( - `${req.session.offline.profile.id}_${+new Date()}_${upload.name}`, + `${req.session.web.profile.id}_${+new Date()}_${upload.name}`, ); const filePath = path.resolve(dirPath, sanitizedFilename); @@ -764,7 +761,7 @@ export default class BaseWebChannelHandler< * * @returns The channel's data */ - protected getChannelData(req: Request | SocketRequest): Offline.ChannelData { + protected getChannelData(req: Request | SocketRequest): Web.ChannelData { return { isSocket: 'isSocket' in req && !!req.isSocket, ipAddress: this.getIpAddress(req), @@ -782,13 +779,13 @@ export default class BaseWebChannelHandler< req: Request | SocketRequest, res: Response | SocketResponse, ): void { - const data: Offline.IncomingMessage = req.body; + const data: Web.IncomingMessage = req.body; this.validateSession(req, res, (profile) => { this.handleFilesUpload( req, res, // @ts-expect-error @TODO : This needs to be fixed at a later point @TODO - (err: Error, upload: Offline.IncomingMessageData) => { + (err: Error, upload: Web.IncomingMessageData) => { if (err) { this.logger.warn( 'Web Channel Handler : Unable to upload file ', @@ -803,7 +800,7 @@ export default class BaseWebChannelHandler< data.data = upload; } const channelData = this.getChannelData(req); - const event: OfflineEventWrapper = new OfflineEventWrapper( + const event: WebEventWrapper = new WebEventWrapper( this, data, channelData, @@ -845,14 +842,14 @@ export default class BaseWebChannelHandler< } /** - * Process incoming Offline data (finding out its type and assigning it to its proper handler) + * Process incoming Web Channel data (finding out its type and assigning it to its proper handler) * * @param req * @param res */ async handle(req: Request | SocketRequest, res: Response | SocketResponse) { const settings = await this.getSettings(); - // Offline messaging can be done through websockets or long-polling + // Web Channel messaging can be done through websockets or long-polling try { await this.checkRequest(req, res); if (req.method === 'GET') { @@ -888,7 +885,7 @@ export default class BaseWebChannelHandler< .json({ err: 'Webhook received unknown command' }); } } else if (req.query._disconnect) { - req.session.offline = undefined; + req.session.web = undefined; return res.status(200).json({ _disconnect: true }); } else { // Handle webhook subscribe requests @@ -912,7 +909,7 @@ export default class BaseWebChannelHandler< * @returns UUID */ generateId(): string { - return 'offline-' + uuidv4(); + return 'web-' + uuidv4(); } /** @@ -926,9 +923,9 @@ export default class BaseWebChannelHandler< _textFormat( message: StdOutgoingTextMessage, _options?: BlockOptions, - ): Offline.OutgoingMessageBase { + ): Web.OutgoingMessageBase { return { - type: Offline.OutgoingMessageType.text, + type: Web.OutgoingMessageType.text, data: message, }; } @@ -944,9 +941,9 @@ export default class BaseWebChannelHandler< _quickRepliesFormat( message: StdOutgoingQuickRepliesMessage, _options?: BlockOptions, - ): Offline.OutgoingMessageBase { + ): Web.OutgoingMessageBase { return { - type: Offline.OutgoingMessageType.quick_replies, + type: Web.OutgoingMessageType.quick_replies, data: { text: message.text, quick_replies: message.quickReplies, @@ -965,9 +962,9 @@ export default class BaseWebChannelHandler< _buttonsFormat( message: StdOutgoingButtonsMessage, _options?: BlockOptions, - ): Offline.OutgoingMessageBase { + ): Web.OutgoingMessageBase { return { - type: Offline.OutgoingMessageType.buttons, + type: Web.OutgoingMessageType.buttons, data: { text: message.text, buttons: message.buttons, @@ -986,9 +983,9 @@ export default class BaseWebChannelHandler< _attachmentFormat( message: StdOutgoingAttachmentMessage>, _options?: BlockOptions, - ): Offline.OutgoingMessageBase { - const payload: Offline.OutgoingMessageBase = { - type: Offline.OutgoingMessageType.file, + ): Web.OutgoingMessageBase { + const payload: Web.OutgoingMessageBase = { + type: Web.OutgoingMessageType.file, data: { type: message.attachment.type, url: message.attachment.payload.url, @@ -1008,10 +1005,7 @@ export default class BaseWebChannelHandler< * * @returns An array of elements object */ - _formatElements( - data: any[], - options: BlockOptions, - ): Offline.MessageElement[] { + _formatElements(data: any[], options: BlockOptions): Web.MessageElement[] { if (!options.content || !options.content.fields) { throw new Error('Content options are missing the fields'); } @@ -1019,7 +1013,7 @@ export default class BaseWebChannelHandler< const fields = options.content.fields; const buttons: Button[] = options.content.buttons; return data.map((item) => { - const element: Offline.MessageElement = { + const element: Web.MessageElement = { title: item[fields.title], buttons: item.buttons || [], }; @@ -1032,7 +1026,7 @@ export default class BaseWebChannelHandler< if (!attachmentPayload.id) { // @deprecated this.logger.warn( - 'Offline Channel Handler: Attachment remote url has been deprecated', + 'Web Channel Handler: Attachment remote url has been deprecated', item, ); } @@ -1091,11 +1085,11 @@ export default class BaseWebChannelHandler< _listFormat( message: StdOutgoingListMessage, options: BlockOptions, - ): Offline.OutgoingMessageBase { + ): Web.OutgoingMessageBase { const data = message.elements || []; const pagination = message.pagination; let buttons: Button[] = [], - elements: Offline.MessageElement[] = []; + elements: Web.MessageElement[] = []; // Items count min check if (!data.length) { @@ -1124,7 +1118,7 @@ export default class BaseWebChannelHandler< } : {}; return { - type: Offline.OutgoingMessageType.list, + type: Web.OutgoingMessageType.list, data: { elements, buttons, @@ -1144,7 +1138,7 @@ export default class BaseWebChannelHandler< _carouselFormat( message: StdOutgoingListMessage, options: BlockOptions, - ): Offline.OutgoingMessageBase { + ): Web.OutgoingMessageBase { const data = message.elements || []; // Items count min check if (data.length === 0) { @@ -1157,7 +1151,7 @@ export default class BaseWebChannelHandler< // Populate items (elements/cards) with content const elements = this._formatElements(data, options); return { - type: Offline.OutgoingMessageType.carousel, + type: Web.OutgoingMessageType.carousel, data: { elements, }, @@ -1175,7 +1169,7 @@ export default class BaseWebChannelHandler< _formatMessage( envelope: StdOutgoingEnvelope, options: BlockOptions, - ): Offline.OutgoingMessageBase { + ): Web.OutgoingMessageBase { switch (envelope.format) { case OutgoingMessageFormat.attachment: return this._attachmentFormat(envelope.message, options); @@ -1215,14 +1209,14 @@ export default class BaseWebChannelHandler< } /** - * Send a Offline Message to the end-user + * Send a Web Channel Message to the end-user * * @param event - Incoming event/message being responded to * @param envelope - The message to be sent {format, message} * @param options - Might contain additional settings * @param _context - Contextual data * - * @returns The offline's response, otherwise an error + * @returns The web's response, otherwise an error */ async sendMessage( event: EventWrapper, @@ -1230,13 +1224,13 @@ export default class BaseWebChannelHandler< options: BlockOptions, _context?: any, ): Promise<{ mid: string }> { - const messageBase: Offline.OutgoingMessageBase = this._formatMessage( + const messageBase: Web.OutgoingMessageBase = this._formatMessage( envelope, options, ); const subscriber = event.getSender(); - const message: Offline.OutgoingMessage = { + const message: Web.OutgoingMessage = { ...messageBase, mid: this.generateId(), author: 'chatbot', @@ -1297,9 +1291,9 @@ export default class BaseWebChannelHandler< * * @param event - The message event received * - * @returns The offline's response, otherwise an error + * @returns The web's response, otherwise an error */ - async getUserData(event: OfflineEventWrapper): Promise { + async getUserData(event: WebEventWrapper): Promise { return event.getSender() as SubscriberCreateDto; } } diff --git a/api/src/extensions/channels/offline/i18n/en/label.json b/api/src/extensions/channels/web/i18n/en/label.json similarity index 100% rename from api/src/extensions/channels/offline/i18n/en/label.json rename to api/src/extensions/channels/web/i18n/en/label.json diff --git a/api/src/extensions/channels/web/i18n/en/title.json b/api/src/extensions/channels/web/i18n/en/title.json new file mode 100644 index 000000000..abf9d8349 --- /dev/null +++ b/api/src/extensions/channels/web/i18n/en/title.json @@ -0,0 +1,3 @@ +{ + "web_channel": "Web Channel" +} diff --git a/api/src/extensions/channels/offline/i18n/fr/label.json b/api/src/extensions/channels/web/i18n/fr/label.json similarity index 100% rename from api/src/extensions/channels/offline/i18n/fr/label.json rename to api/src/extensions/channels/web/i18n/fr/label.json diff --git a/api/src/extensions/channels/web/i18n/fr/title.json b/api/src/extensions/channels/web/i18n/fr/title.json new file mode 100644 index 000000000..80a293eaa --- /dev/null +++ b/api/src/extensions/channels/web/i18n/fr/title.json @@ -0,0 +1,3 @@ +{ + "web_channel": "Canal Web" +} diff --git a/api/src/extensions/channels/offline/index.channel.ts b/api/src/extensions/channels/web/index.channel.ts similarity index 88% rename from api/src/extensions/channels/offline/index.channel.ts rename to api/src/extensions/channels/web/index.channel.ts index e7be89aea..8432892c5 100644 --- a/api/src/extensions/channels/offline/index.channel.ts +++ b/api/src/extensions/channels/web/index.channel.ts @@ -20,11 +20,11 @@ import { SettingService } from '@/setting/services/setting.service'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; import BaseWebChannelHandler from './base-web-channel'; -import { DEFAULT_OFFLINE_SETTINGS, OFFLINE_CHANNEL_NAME } from './settings'; +import { WEB_CHANNEL_NAME } from './settings'; @Injectable() -export default class OfflineHandler extends BaseWebChannelHandler< - typeof OFFLINE_CHANNEL_NAME +export default class WebChannelHandler extends BaseWebChannelHandler< + typeof WEB_CHANNEL_NAME > { constructor( settingService: SettingService, @@ -39,8 +39,7 @@ export default class OfflineHandler extends BaseWebChannelHandler< websocketGateway: WebsocketGateway, ) { super( - OFFLINE_CHANNEL_NAME, - DEFAULT_OFFLINE_SETTINGS, + WEB_CHANNEL_NAME, settingService, channelService, logger, @@ -53,4 +52,8 @@ export default class OfflineHandler extends BaseWebChannelHandler< websocketGateway, ); } + + getPath(): string { + return __dirname; + } } diff --git a/api/src/extensions/channels/web/index.d.ts b/api/src/extensions/channels/web/index.d.ts new file mode 100644 index 000000000..4261bea52 --- /dev/null +++ b/api/src/extensions/channels/web/index.d.ts @@ -0,0 +1,24 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import DEFAULT_WEB_CHANNEL_SETTINGS, { + WEB_CHANNEL_NAMESPACE, +} from './settings'; + +declare global { + interface Settings extends SettingTree {} +} + +declare module '@nestjs/event-emitter' { + interface IHookExtensionsOperationMap { + [WEB_CHANNEL_NAMESPACE]: TDefinition< + object, + SettingMapByType + >; + } +} diff --git a/api/src/extensions/channels/web/package.json b/api/src/extensions/channels/web/package.json new file mode 100644 index 000000000..0da6429a8 --- /dev/null +++ b/api/src/extensions/channels/web/package.json @@ -0,0 +1,7 @@ +{ + "name": "hexabot-channel-web", + "version": "2.0.0", + "description": "The Web Channel Extension for Hexabot Chatbot / Agent Builder for website integration", + "author": "Hexastack", + "license": "AGPL-3.0-only" +} \ No newline at end of file diff --git a/api/src/extensions/channels/offline/settings.ts b/api/src/extensions/channels/web/settings.ts similarity index 64% rename from api/src/extensions/channels/offline/settings.ts rename to api/src/extensions/channels/web/settings.ts index 4d93c56e9..1011774ca 100644 --- a/api/src/extensions/channels/offline/settings.ts +++ b/api/src/extensions/channels/web/settings.ts @@ -9,97 +9,97 @@ import { ChannelSetting } from '@/channel/types'; import { SettingType } from '@/setting/schemas/types'; -import { Offline } from './types'; +import { Web } from './types'; -export const OFFLINE_CHANNEL_NAME = 'offline' as const; +export const WEB_CHANNEL_NAME = 'web-channel' as const; -export const OFFLINE_GROUP_NAME = OFFLINE_CHANNEL_NAME; +export const WEB_CHANNEL_NAMESPACE = 'web_channel'; -export const DEFAULT_OFFLINE_SETTINGS = [ +export default [ { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.verification_token, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.verification_token, value: 'token123', type: SettingType.secret, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.allowed_domains, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.allowed_domains, value: 'http://localhost:8080,http://localhost:4000', type: SettingType.text, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.start_button, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.start_button, value: true, type: SettingType.checkbox, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.input_disabled, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.input_disabled, value: false, type: SettingType.checkbox, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.persistent_menu, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.persistent_menu, value: true, type: SettingType.checkbox, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.greeting_message, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.greeting_message, value: 'Welcome! Ready to start a conversation with our chatbot?', type: SettingType.textarea, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.theme_color, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.theme_color, value: 'teal', type: SettingType.select, options: ['teal', 'orange', 'red', 'green', 'blue', 'dark'], }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.window_title, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.window_title, value: 'Widget Title', type: SettingType.text, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.avatar_url, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.avatar_url, value: 'https://eu.ui-avatars.com/api/?name=Hexa+Bot&size=64', type: SettingType.text, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.show_emoji, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.show_emoji, value: true, type: SettingType.checkbox, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.show_file, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.show_file, value: true, type: SettingType.checkbox, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.show_location, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.show_location, value: true, type: SettingType.checkbox, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.allowed_upload_size, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.allowed_upload_size, value: 2500000, type: SettingType.number, }, { - group: OFFLINE_GROUP_NAME, - label: Offline.SettingLabel.allowed_upload_types, + group: WEB_CHANNEL_NAMESPACE, + label: Web.SettingLabel.allowed_upload_types, value: 'audio/mpeg,audio/x-ms-wma,audio/vnd.rn-realaudio,audio/x-wav,image/gif,image/jpeg,image/png,image/tiff,image/vnd.microsoft.icon,image/vnd.djvu,image/svg+xml,text/css,text/csv,text/html,text/plain,text/xml,video/mpeg,video/mp4,video/quicktime,video/x-ms-wmv,video/x-msvideo,video/x-flv,video/web,application/msword,application/vnd.ms-powerpoint,application/pdf,application/vnd.ms-excel,application/vnd.oasis.opendocument.presentation,application/vnd.oasis.opendocument.tex,application/vnd.oasis.opendocument.spreadsheet,application/vnd.oasis.opendocument.graphics,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document', type: SettingType.textarea, }, -] as const satisfies ChannelSetting[]; +] as const satisfies ChannelSetting[]; diff --git a/api/src/extensions/channels/offline/types.ts b/api/src/extensions/channels/web/types.ts similarity index 98% rename from api/src/extensions/channels/offline/types.ts rename to api/src/extensions/channels/web/types.ts index c202bdf30..7442f8378 100644 --- a/api/src/extensions/channels/offline/types.ts +++ b/api/src/extensions/channels/web/types.ts @@ -11,7 +11,7 @@ import { Button, WebUrlButton } from '@/chat/schemas/types/button'; import { FileType } from '@/chat/schemas/types/message'; import { StdQuickReply } from '@/chat/schemas/types/quick-reply'; -export namespace Offline { +export namespace Web { export enum SettingLabel { secret = 'secret', verification_token = 'verification_token', @@ -39,7 +39,7 @@ export namespace Offline { }; export type RequestSession = { - offline?: { + web?: { profile: SubscriberFull; isSocket: boolean; messageQueue: any[]; @@ -61,7 +61,7 @@ export namespace Offline { file = 'file', } - export type EventType = Offline.StatusEventType | Offline.IncomingMessageType; + export type EventType = Web.StatusEventType | Web.IncomingMessageType; export enum OutgoingMessageType { text = 'text', diff --git a/api/src/extensions/channels/offline/wrapper.ts b/api/src/extensions/channels/web/wrapper.ts similarity index 87% rename from api/src/extensions/channels/offline/wrapper.ts rename to api/src/extensions/channels/web/wrapper.ts index 5ab278d07..e36ff5d9b 100644 --- a/api/src/extensions/channels/offline/wrapper.ts +++ b/api/src/extensions/channels/web/wrapper.ts @@ -7,6 +7,7 @@ */ import EventWrapper from '@/channel/lib/EventWrapper'; +import { ChannelName } from '@/channel/types'; import { AttachmentForeignKey, AttachmentPayload, @@ -20,55 +21,56 @@ import { import { Payload } from '@/chat/schemas/types/quick-reply'; import BaseWebChannelHandler from './base-web-channel'; -import { Offline } from './types'; +import { Web } from './types'; -type OfflineEventAdapter = +type WebEventAdapter = | { eventType: StdEventType.unknown; messageType: never; - raw: Offline.Event; + raw: Web.Event; } | { eventType: StdEventType.read; messageType: never; - raw: Offline.StatusReadEvent; + raw: Web.StatusReadEvent; } | { eventType: StdEventType.delivery; messageType: never; - raw: Offline.StatusDeliveryEvent; + raw: Web.StatusDeliveryEvent; } | { eventType: StdEventType.typing; messageType: never; - raw: Offline.StatusTypingEvent; + raw: Web.StatusTypingEvent; } | { eventType: StdEventType.message; messageType: IncomingMessageType.message; - raw: Offline.IncomingMessage; + raw: Web.IncomingMessage; } | { eventType: StdEventType.message; messageType: | IncomingMessageType.postback | IncomingMessageType.quick_reply; - raw: Offline.IncomingMessage; + raw: Web.IncomingMessage; } | { eventType: StdEventType.message; messageType: IncomingMessageType.location; - raw: Offline.IncomingMessage; + raw: Web.IncomingMessage; } | { eventType: StdEventType.message; messageType: IncomingMessageType.attachments; - raw: Offline.IncomingMessage; + raw: Web.IncomingMessage; }; -export default class OfflineEventWrapper< - T extends BaseWebChannelHandler = BaseWebChannelHandler, -> extends EventWrapper { +export default class WebEventWrapper< + T extends + BaseWebChannelHandler = BaseWebChannelHandler, +> extends EventWrapper { /** * Constructor : channel's event wrapper * @@ -76,7 +78,7 @@ export default class OfflineEventWrapper< * @param event - The message event received * @param channelData - Channel's specific extra data {isSocket, ipAddress} */ - constructor(handler: T, event: Offline.Event, channelData: any) { + constructor(handler: T, event: Web.Event, channelData: any) { super(handler, event, channelData); } @@ -88,34 +90,34 @@ export default class OfflineEventWrapper< * * @param event - The message event received */ - _init(event: Offline.Event) { + _init(event: Web.Event) { switch (event.type) { - case Offline.StatusEventType.delivery: + case Web.StatusEventType.delivery: this._adapter.eventType = StdEventType.delivery; break; - case Offline.StatusEventType.read: + case Web.StatusEventType.read: this._adapter.eventType = StdEventType.read; break; - case Offline.StatusEventType.typing: + case Web.StatusEventType.typing: this._adapter.eventType = StdEventType.typing; break; - case Offline.IncomingMessageType.text: + case Web.IncomingMessageType.text: this._adapter.eventType = StdEventType.message; this._adapter.messageType = IncomingMessageType.message; break; - case Offline.IncomingMessageType.quick_reply: + case Web.IncomingMessageType.quick_reply: this._adapter.eventType = StdEventType.message; this._adapter.messageType = IncomingMessageType.quick_reply; break; - case Offline.IncomingMessageType.postback: + case Web.IncomingMessageType.postback: this._adapter.eventType = StdEventType.message; this._adapter.messageType = IncomingMessageType.postback; break; - case Offline.IncomingMessageType.location: + case Web.IncomingMessageType.location: this._adapter.eventType = StdEventType.message; this._adapter.messageType = IncomingMessageType.location; break; - case Offline.IncomingMessageType.file: + case Web.IncomingMessageType.file: this._adapter.eventType = StdEventType.message; this._adapter.messageType = IncomingMessageType.attachments; break; diff --git a/api/src/extensions/helpers/core-nlu/__test__/index.spec.ts b/api/src/extensions/helpers/core-nlu/__test__/index.spec.ts index 9870b8a5f..ef5aef235 100644 --- a/api/src/extensions/helpers/core-nlu/__test__/index.spec.ts +++ b/api/src/extensions/helpers/core-nlu/__test__/index.spec.ts @@ -58,7 +58,7 @@ describe('Core NLU Helper', () => { provide: SettingService, useValue: { getSettings: jest.fn(() => ({ - core_nlu: { + core_nlu_helper: { endpoint: 'path', token: 'token', threshold: '0.5', @@ -121,7 +121,7 @@ describe('Core NLU Helper', () => { true, ); const settings = await settingService.getSettings(); - const threshold = settings.core_nlu.threshold; + const threshold = settings.core_nlu_helper.threshold; const thresholdGuess = { entities: nlpBestGuess.entities.filter( (g) => diff --git a/api/src/extensions/helpers/core-nlu/i18n/en/title.json b/api/src/extensions/helpers/core-nlu/i18n/en/title.json index 70534a3dd..ded4a46a0 100644 --- a/api/src/extensions/helpers/core-nlu/i18n/en/title.json +++ b/api/src/extensions/helpers/core-nlu/i18n/en/title.json @@ -1,3 +1,3 @@ { - "core_nlu": "Core NLU Engine" + "core_nlu_helper": "Core NLU Engine" } diff --git a/api/src/extensions/helpers/core-nlu/i18n/fr/title.json b/api/src/extensions/helpers/core-nlu/i18n/fr/title.json index 70534a3dd..ded4a46a0 100644 --- a/api/src/extensions/helpers/core-nlu/i18n/fr/title.json +++ b/api/src/extensions/helpers/core-nlu/i18n/fr/title.json @@ -1,3 +1,3 @@ { - "core_nlu": "Core NLU Engine" + "core_nlu_helper": "Core NLU Engine" } diff --git a/api/src/extensions/helpers/core-nlu/index.d.ts b/api/src/extensions/helpers/core-nlu/index.d.ts index 00c0d3c8f..cce928d23 100644 --- a/api/src/extensions/helpers/core-nlu/index.d.ts +++ b/api/src/extensions/helpers/core-nlu/index.d.ts @@ -1,4 +1,14 @@ -import { CORE_NLU_HELPER_GROUP, CORE_NLU_HELPER_SETTINGS } from './settings'; +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import CORE_NLU_HELPER_SETTINGS, { + CORE_NLU_HELPER_NAMESPACE, +} from './settings'; declare global { interface Settings extends SettingTree {} @@ -6,7 +16,7 @@ declare global { declare module '@nestjs/event-emitter' { interface IHookExtensionsOperationMap { - [CORE_NLU_HELPER_GROUP]: TDefinition< + [CORE_NLU_HELPER_NAMESPACE]: TDefinition< object, SettingMapByType >; diff --git a/api/src/extensions/helpers/core-nlu/index.helper.ts b/api/src/extensions/helpers/core-nlu/index.helper.ts index 58327ef16..6a2ebd395 100644 --- a/api/src/extensions/helpers/core-nlu/index.helper.ts +++ b/api/src/extensions/helpers/core-nlu/index.helper.ts @@ -20,7 +20,7 @@ import { NlpValue } from '@/nlp/schemas/nlp-value.schema'; import { SettingService } from '@/setting/services/setting.service'; import { buildURL } from '@/utils/helpers/URL'; -import { CORE_NLU_HELPER_NAME, CORE_NLU_HELPER_SETTINGS } from './settings'; +import { CORE_NLU_HELPER_NAME } from './settings'; import { NlpParseResultType, RasaNlu } from './types'; @Injectable() @@ -34,13 +34,11 @@ export default class CoreNluHelper extends BaseNlpHelper< private readonly httpService: HttpService, private readonly languageService: LanguageService, ) { - super( - CORE_NLU_HELPER_NAME, - CORE_NLU_HELPER_SETTINGS, - settingService, - helperService, - logger, - ); + super(CORE_NLU_HELPER_NAME, settingService, helperService, logger); + } + + getPath() { + return __dirname; } /** diff --git a/api/src/extensions/helpers/core-nlu/package.json b/api/src/extensions/helpers/core-nlu/package.json index 02413a2d9..c35cb4406 100644 --- a/api/src/extensions/helpers/core-nlu/package.json +++ b/api/src/extensions/helpers/core-nlu/package.json @@ -1,8 +1,8 @@ { - "name": "hexabot-core-nlu", + "name": "hexabot-helper-core-nlu", "version": "2.0.0", "description": "The Core NLU Helper Extension for Hexabot Chatbot / Agent Builder to enable the Intent Classification and Language Detection", "dependencies": {}, "author": "Hexastack", "license": "AGPL-3.0-only" -} +} \ No newline at end of file diff --git a/api/src/extensions/helpers/core-nlu/settings.ts b/api/src/extensions/helpers/core-nlu/settings.ts index 3a5f3d838..5a41e10d2 100644 --- a/api/src/extensions/helpers/core-nlu/settings.ts +++ b/api/src/extensions/helpers/core-nlu/settings.ts @@ -1,25 +1,33 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + import { HelperSetting } from '@/helper/types'; import { SettingType } from '@/setting/schemas/types'; -export const CORE_NLU_HELPER_NAME = 'core-nlu'; +export const CORE_NLU_HELPER_NAME = 'core-nlu-helper'; -export const CORE_NLU_HELPER_GROUP = 'core_nlu'; +export const CORE_NLU_HELPER_NAMESPACE = 'core_nlu_helper'; -export const CORE_NLU_HELPER_SETTINGS = [ +export default [ { - group: CORE_NLU_HELPER_GROUP, + group: CORE_NLU_HELPER_NAMESPACE, label: 'endpoint', value: 'http://nlu-api:5000/', type: SettingType.text, }, { - group: CORE_NLU_HELPER_GROUP, + group: CORE_NLU_HELPER_NAMESPACE, label: 'token', value: 'token123', type: SettingType.text, }, { - group: CORE_NLU_HELPER_GROUP, + group: CORE_NLU_HELPER_NAMESPACE, label: 'threshold', value: 0.1, type: SettingType.number, diff --git a/api/src/extensions/helpers/ollama/i18n/en/title.json b/api/src/extensions/helpers/ollama/i18n/en/title.json index 85109553b..a51ce0090 100644 --- a/api/src/extensions/helpers/ollama/i18n/en/title.json +++ b/api/src/extensions/helpers/ollama/i18n/en/title.json @@ -1,3 +1,3 @@ { - "ollama": "Ollama" + "ollama_helper": "Ollama" } diff --git a/api/src/extensions/helpers/ollama/i18n/fr/title.json b/api/src/extensions/helpers/ollama/i18n/fr/title.json index 85109553b..a51ce0090 100644 --- a/api/src/extensions/helpers/ollama/i18n/fr/title.json +++ b/api/src/extensions/helpers/ollama/i18n/fr/title.json @@ -1,3 +1,3 @@ { - "ollama": "Ollama" + "ollama_helper": "Ollama" } diff --git a/api/src/extensions/helpers/ollama/index.d.ts b/api/src/extensions/helpers/ollama/index.d.ts index e922cc4f9..0dfcdf64f 100644 --- a/api/src/extensions/helpers/ollama/index.d.ts +++ b/api/src/extensions/helpers/ollama/index.d.ts @@ -1,4 +1,12 @@ -import { OLLAMA_HELPER_GROUP, OLLAMA_HELPER_SETTINGS } from './settings'; +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import OLLAMA_HELPER_SETTINGS, { OLLAMA_HELPER_NAMESPACE } from './settings'; declare global { interface Settings extends SettingTree {} @@ -6,7 +14,7 @@ declare global { declare module '@nestjs/event-emitter' { interface IHookExtensionsOperationMap { - [OLLAMA_HELPER_GROUP]: TDefinition< + [OLLAMA_HELPER_NAMESPACE]: TDefinition< object, SettingMapByType >; diff --git a/api/src/extensions/helpers/ollama/index.helper.ts b/api/src/extensions/helpers/ollama/index.helper.ts index f1d14fc4f..cdc619fd1 100644 --- a/api/src/extensions/helpers/ollama/index.helper.ts +++ b/api/src/extensions/helpers/ollama/index.helper.ts @@ -17,7 +17,7 @@ import { LoggerService } from '@/logger/logger.service'; import { Setting } from '@/setting/schemas/setting.schema'; import { SettingService } from '@/setting/services/setting.service'; -import { OLLAMA_HELPER_NAME, OLLAMA_HELPER_SETTINGS } from './settings'; +import { OLLAMA_HELPER_NAME } from './settings'; @Injectable() export default class OllamaLlmHelper @@ -36,13 +36,11 @@ export default class OllamaLlmHelper helperService: HelperService, protected readonly logger: LoggerService, ) { - super( - OLLAMA_HELPER_NAME, - OLLAMA_HELPER_SETTINGS, - settingService, - helperService, - logger, - ); + super('ollama-helper', settingService, helperService, logger); + } + + getPath(): string { + return __dirname; } async onApplicationBootstrap() { @@ -51,7 +49,7 @@ export default class OllamaLlmHelper this.client = new Ollama({ host: settings.api_url }); } - @OnEvent('hook:ollama:api_url') + @OnEvent('hook:ollama_helper:api_url') handleApiUrlChange(setting: Setting) { this.client = new Ollama({ host: setting.value }); } diff --git a/api/src/extensions/helpers/ollama/settings.ts b/api/src/extensions/helpers/ollama/settings.ts index 0d2341344..7691f0e8b 100644 --- a/api/src/extensions/helpers/ollama/settings.ts +++ b/api/src/extensions/helpers/ollama/settings.ts @@ -1,123 +1,133 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + import { HelperSetting } from '@/helper/types'; import { SettingType } from '@/setting/schemas/types'; -export const OLLAMA_HELPER_NAME = 'ollama'; +export const OLLAMA_HELPER_NAME = 'ollama-helper'; -export const OLLAMA_HELPER_GROUP = 'ollama'; +export const OLLAMA_HELPER_NAMESPACE: HyphenToUnderscore< + typeof OLLAMA_HELPER_NAME +> = 'ollama_helper'; -export const OLLAMA_HELPER_SETTINGS = [ +export default [ { label: 'api_url', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, type: SettingType.text, value: 'http://ollama:11434', // Default value }, { label: 'model', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, type: SettingType.text, value: 'llama3.2', // Default model }, { label: 'keep_alive', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, type: SettingType.text, value: '5m', // Default value for keeping the model in memory }, { label: 'mirostat', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 0, // Default: disabled }, { label: 'mirostat_eta', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 0.1, // Default value }, { label: 'mirostat_tau', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 5.0, // Default value }, { label: 'num_ctx', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 2048, // Default value }, { label: 'repeat_last_n', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 64, // Default value }, { label: 'repeat_penalty', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 1.1, // Default value }, { label: 'temperature', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 0.8, // Default value }, { label: 'seed', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 0, // Default value }, { label: 'stop', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.text, value: 'AI assistant:', // Default stop sequence }, { label: 'tfs_z', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 1, // Default value, 1.0 means disabled }, { label: 'num_predict', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 20, // Default value }, { label: 'top_k', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 40, // Default value }, { label: 'top_p', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 0.9, // Default value }, { label: 'min_p', - group: OLLAMA_HELPER_GROUP, + group: OLLAMA_HELPER_NAMESPACE, subgroup: 'options', type: SettingType.number, value: 0.0, // Default value diff --git a/api/src/extensions/plugins/ollama/i18n/en/title.json b/api/src/extensions/plugins/ollama/i18n/en/title.json index 85109553b..403d3c310 100644 --- a/api/src/extensions/plugins/ollama/i18n/en/title.json +++ b/api/src/extensions/plugins/ollama/i18n/en/title.json @@ -1,3 +1,3 @@ { - "ollama": "Ollama" + "ollama_plugin": "Ollama Plugin" } diff --git a/api/src/extensions/plugins/ollama/i18n/fr/title.json b/api/src/extensions/plugins/ollama/i18n/fr/title.json index 85109553b..403d3c310 100644 --- a/api/src/extensions/plugins/ollama/i18n/fr/title.json +++ b/api/src/extensions/plugins/ollama/i18n/fr/title.json @@ -1,3 +1,3 @@ { - "ollama": "Ollama" + "ollama_plugin": "Ollama Plugin" } diff --git a/api/src/extensions/plugins/ollama/index.plugin.ts b/api/src/extensions/plugins/ollama/index.plugin.ts index bcf0ea10e..2d8d7b66b 100644 --- a/api/src/extensions/plugins/ollama/index.plugin.ts +++ b/api/src/extensions/plugins/ollama/index.plugin.ts @@ -1,3 +1,11 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + import { Injectable } from '@nestjs/common'; import { Block } from '@/chat/schemas/block.schema'; @@ -14,14 +22,13 @@ import { HelperType } from '@/helper/types'; import { LoggerService } from '@/logger/logger.service'; import { BaseBlockPlugin } from '@/plugins/base-block-plugin'; import { PluginService } from '@/plugins/plugins.service'; +import { PluginBlockTemplate } from '@/plugins/types'; -import { OLLAMA_PLUGIN_SETTINGS } from './settings'; +import SETTINGS from './settings'; @Injectable() -export class OllamaPlugin extends BaseBlockPlugin< - typeof OLLAMA_PLUGIN_SETTINGS -> { - public readonly settings = OLLAMA_PLUGIN_SETTINGS; +export class OllamaPlugin extends BaseBlockPlugin { + template: PluginBlockTemplate = { name: 'Ollama Plugin' }; constructor( pluginService: PluginService, @@ -30,12 +37,11 @@ export class OllamaPlugin extends BaseBlockPlugin< private contentService: ContentService, private messageService: MessageService, ) { - super('ollama', OLLAMA_PLUGIN_SETTINGS, pluginService); + super('ollama-plugin', pluginService); + } - this.template = { name: 'Ollama Plugin' }; - this.effects = { - onStoreContextData: () => {}, - }; + getPath(): string { + return __dirname; } async process(block: Block, context: Context, _convId: string) { diff --git a/api/src/extensions/plugins/ollama/package.json b/api/src/extensions/plugins/ollama/package.json index 049ce5817..a9b0f46b6 100644 --- a/api/src/extensions/plugins/ollama/package.json +++ b/api/src/extensions/plugins/ollama/package.json @@ -1,11 +1,10 @@ { - "name": "hexabot-ollama", + "name": "hexabot-plugin-ollama", "version": "2.0.0", "description": "The Ollama Plugin Extension for Hexabot Chatbot / Agent Builder that provides a custom block for Generative AI + RAG", - "dependencies": {}, - "extensions": { + "dependencies": { "hexabot-helper-ollama": "2.0.0" }, "author": "Hexastack", "license": "AGPL-3.0-only" -} +} \ No newline at end of file diff --git a/api/src/extensions/plugins/ollama/settings.ts b/api/src/extensions/plugins/ollama/settings.ts index 4289d1cc7..a1b886e6a 100644 --- a/api/src/extensions/plugins/ollama/settings.ts +++ b/api/src/extensions/plugins/ollama/settings.ts @@ -1,7 +1,15 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + import { PluginSetting } from '@/plugins/types'; import { SettingType } from '@/setting/schemas/types'; -export const OLLAMA_PLUGIN_SETTINGS = [ +export default [ { label: 'model', group: 'default', diff --git a/api/src/helper/helper.module.ts b/api/src/helper/helper.module.ts index 5cb84f2bc..bd8888c8e 100644 --- a/api/src/helper/helper.module.ts +++ b/api/src/helper/helper.module.ts @@ -14,7 +14,12 @@ import { HelperController } from './helper.controller'; import { HelperService } from './helper.service'; @Global() -@InjectDynamicProviders('dist/extensions/**/*.helper.js') +@InjectDynamicProviders( + // Core & under dev helpers + 'dist/extensions/**/*.helper.js', + // Installed helpers via npm + 'dist/.hexabot/helpers/**/*.helper.js', +) @Module({ imports: [HttpModule], controllers: [HelperController], diff --git a/api/src/helper/helper.service.ts b/api/src/helper/helper.service.ts index ddd07d9d5..fb3e6bfb2 100644 --- a/api/src/helper/helper.service.ts +++ b/api/src/helper/helper.service.ts @@ -12,7 +12,7 @@ import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; import BaseHelper from './lib/base-helper'; -import { HelperRegistry, HelperType, TypeOfHelper } from './types'; +import { HelperName, HelperRegistry, HelperType, TypeOfHelper } from './types'; @Injectable() export class HelperService { @@ -33,7 +33,7 @@ export class HelperService { * * @param name - The helper to be registered. */ - public register>(helper: H) { + public register(helper: H) { const helpers = this.registry.get(helper.getType()); helpers.set(helper.getName(), helper); this.logger.log(`Helper "${helper.getName()}" has been registered!`); @@ -47,7 +47,7 @@ export class HelperService { * * @returns - The helper */ - public get(type: T, name: string) { + public get(type: T, name: HelperName) { const helpers = this.registry.get(type); if (!helpers.has(name)) { @@ -67,6 +67,16 @@ export class HelperService { return Array.from(helpers.values()); } + /** + * Retrieves all registered helpers as an array. + * + * @returns An array containing all the registered helpers. + */ + public getAll(): BaseHelper[] { + 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 + } + /** * Get a helper by class. * @@ -100,7 +110,7 @@ export class HelperService { const defaultHelper = this.get( HelperType.NLU, - settings.chatbot_settings.default_nlu_helper, + settings.chatbot_settings.default_nlu_helper as HelperName, ); if (!defaultHelper) { @@ -120,7 +130,7 @@ export class HelperService { const defaultHelper = this.get( HelperType.LLM, - settings.chatbot_settings.default_llm_helper, + settings.chatbot_settings.default_llm_helper as HelperName, ); if (!defaultHelper) { diff --git a/api/src/helper/lib/base-helper.ts b/api/src/helper/lib/base-helper.ts index 2597b175c..4ef91ad02 100644 --- a/api/src/helper/lib/base-helper.ts +++ b/api/src/helper/lib/base-helper.ts @@ -6,33 +6,37 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { LoggerService } from '@nestjs/common'; +import path from 'path'; + +import { LoggerService, OnModuleInit } from '@nestjs/common'; import { SettingService } from '@/setting/services/setting.service'; -import { hyphenToUnderscore } from '@/utils/helpers/misc'; +import { Extension } from '@/utils/generics/extension'; import { HelperService } from '../helper.service'; -import { HelperSetting, HelperType } from '../types'; - -export default abstract class BaseHelper { - protected readonly name: N; +import { HelperName, HelperSetting, HelperType } from '../types'; +export default abstract class BaseHelper + extends Extension + implements OnModuleInit +{ protected readonly settings: HelperSetting[] = []; protected abstract type: HelperType; constructor( name: N, - settings: HelperSetting[], protected readonly settingService: SettingService, protected readonly helperService: HelperService, protected readonly logger: LoggerService, ) { - this.name = name; - this.settings = settings; + super(name); + // eslint-disable-next-line @typescript-eslint/no-var-requires + this.settings = require(path.join(this.getPath(), 'settings')).default; } - onModuleInit() { + async onModuleInit() { + await super.onModuleInit(); this.helperService.register(this); this.setup(); } @@ -47,23 +51,6 @@ export default abstract class BaseHelper { ); } - /** - * Returns the helper's name - * - * @returns Helper's name - */ - public getName() { - return this.name; - } - - /** - * Returns the helper's group - * @returns Helper's group - */ - protected getGroup() { - return hyphenToUnderscore(this.getName()) as HelperSetting['group']; - } - /** * Get the helper's type * @@ -81,6 +68,6 @@ export default abstract class BaseHelper { async getSettings>() { const settings = await this.settingService.getSettings(); // @ts-expect-error workaround typing - return settings[this.getGroup() as keyof Settings] as Settings[S]; + return settings[this.getNamespace() as keyof Settings] as Settings[S]; } } diff --git a/api/src/helper/lib/base-llm-helper.ts b/api/src/helper/lib/base-llm-helper.ts index 413e8598c..7765738ef 100644 --- a/api/src/helper/lib/base-llm-helper.ts +++ b/api/src/helper/lib/base-llm-helper.ts @@ -11,23 +11,22 @@ import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; import { HelperService } from '../helper.service'; -import { HelperSetting, HelperType } from '../types'; +import { HelperName, HelperType } from '../types'; import BaseHelper from './base-helper'; export default abstract class BaseLlmHelper< - N extends string, + N extends HelperName = HelperName, > extends BaseHelper { protected readonly type: HelperType = HelperType.LLM; constructor( name: N, - settings: HelperSetting[], settingService: SettingService, helperService: HelperService, logger: LoggerService, ) { - super(name, settings, settingService, helperService, logger); + super(name, settingService, helperService, logger); } /** diff --git a/api/src/helper/lib/base-nlp-helper.ts b/api/src/helper/lib/base-nlp-helper.ts index 302cdd17f..ae2775b3a 100644 --- a/api/src/helper/lib/base-nlp-helper.ts +++ b/api/src/helper/lib/base-nlp-helper.ts @@ -23,23 +23,23 @@ import { import { SettingService } from '@/setting/services/setting.service'; import { HelperService } from '../helper.service'; -import { HelperSetting, HelperType, Nlp } from '../types'; +import { HelperName, HelperType, Nlp } from '../types'; import BaseHelper from './base-helper'; +// eslint-disable-next-line prettier/prettier export default abstract class BaseNlpHelper< - N extends string, + N extends HelperName = HelperName, > extends BaseHelper { protected readonly type: HelperType = HelperType.NLU; constructor( name: N, - settings: HelperSetting[], settingService: SettingService, helperService: HelperService, logger: LoggerService, ) { - super(name, settings, settingService, helperService, logger); + super(name, settingService, helperService, logger); } /** diff --git a/api/src/helper/types.ts b/api/src/helper/types.ts index e3820520c..7692c5a90 100644 --- a/api/src/helper/types.ts +++ b/api/src/helper/types.ts @@ -1,3 +1,11 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + import { SettingCreateDto } from '@/setting/dto/setting.dto'; import BaseHelper from './lib/base-helper'; @@ -29,10 +37,12 @@ export enum HelperType { UTIL = 'util', } +export type HelperName = `${string}-helper`; + export type TypeOfHelper = T extends HelperType.LLM - ? BaseLlmHelper + ? BaseLlmHelper : T extends HelperType.NLU - ? BaseNlpHelper + ? BaseNlpHelper : BaseHelper; export type HelperRegistry = Map< @@ -40,7 +50,7 @@ export type HelperRegistry = Map< Map >; -export type HelperSetting = Omit< +export type HelperSetting = Omit< SettingCreateDto, 'group' | 'weight' > & { diff --git a/api/src/i18n/controllers/i18n.controller.ts b/api/src/i18n/controllers/i18n.controller.ts index be8570474..2e2ce965e 100644 --- a/api/src/i18n/controllers/i18n.controller.ts +++ b/api/src/i18n/controllers/i18n.controller.ts @@ -8,14 +8,19 @@ import { Controller, Get, UseInterceptors } from '@nestjs/common'; +import { ChannelService } from '@/channel/channel.service'; +import { HelperService } from '@/helper/helper.service'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; - -import { I18nService } from '../services/i18n.service'; +import { PluginService } from '@/plugins/plugins.service'; @UseInterceptors(CsrfInterceptor) @Controller('i18n') export class I18nController { - constructor(private readonly i18nService: I18nService) {} + constructor( + private readonly pluginService: PluginService, + private readonly helperService: HelperService, + private readonly channelService: ChannelService, + ) {} /** * Retrieves translations of all the installed extensions. @@ -23,6 +28,12 @@ export class I18nController { */ @Get() getTranslations() { - return this.i18nService.getExtensionI18nTranslations(); + const plugins = this.pluginService.getAll(); + const helpers = this.helperService.getAll(); + const channels = this.channelService.getAll(); + return [...plugins, ...helpers, ...channels].reduce((acc, curr) => { + acc[curr.getNamespace()] = curr.getTranslations(); + return acc; + }, {}); } } diff --git a/api/src/i18n/services/i18n.service.ts b/api/src/i18n/services/i18n.service.ts index 52d4d0639..9e5b93aaa 100644 --- a/api/src/i18n/services/i18n.service.ts +++ b/api/src/i18n/services/i18n.service.ts @@ -6,13 +6,8 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { existsSync, promises as fs } from 'fs'; -import * as path from 'path'; - -import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { - I18nJsonLoader, - I18nTranslation, I18nService as NativeI18nService, Path, PathValue, @@ -22,25 +17,13 @@ import { IfAnyOrNever } from 'nestjs-i18n/dist/types'; import { config } from '@/config'; import { Translation } from '@/i18n/schemas/translation.schema'; -import { hyphenToUnderscore } from '@/utils/helpers/misc'; @Injectable() -export class I18nService> - extends NativeI18nService - implements OnModuleInit -{ +export class I18nService< + K = Record, +> extends NativeI18nService { private dynamicTranslations: Record> = {}; - private extensionTranslations: I18nTranslation = {}; - - onModuleInit() { - this.loadExtensionI18nTranslations(); - } - - getExtensionI18nTranslations() { - return this.extensionTranslations; - } - t

= any, R = PathValue>( key: P, options?: TranslateOptions, @@ -83,52 +66,4 @@ export class I18nService> return acc; }, this.dynamicTranslations); } - - async loadExtensionI18nTranslations() { - const baseDir = path.join(__dirname, '..', '..', 'extensions'); - const extensionTypes = ['channels', 'helpers', 'plugins']; - - try { - for (const type of extensionTypes) { - const extensionsDir = path.join(baseDir, type); - - if (!existsSync(extensionsDir)) { - continue; - } - - const extensionFolders = await fs.readdir(extensionsDir, { - withFileTypes: true, - }); - - for (const folder of extensionFolders) { - if (folder.isDirectory()) { - const i18nPath = path.join(extensionsDir, folder.name, 'i18n'); - const namespace = hyphenToUnderscore(folder.name); - try { - // Check if the i18n directory exists - await fs.access(i18nPath); - - // Load and merge translations - const i18nLoader = new I18nJsonLoader({ path: i18nPath }); - const translations = await i18nLoader.load(); - for (const lang in translations) { - if (!this.extensionTranslations[lang]) { - this.extensionTranslations[lang] = { - [namespace]: translations[lang], - }; - } else { - this.extensionTranslations[lang][namespace] = - translations[lang]; - } - } - } catch (error) { - // If the i18n folder does not exist or error in reading, skip this folder - } - } - } - } - } catch (error) { - throw new Error(`Failed to read extensions directory: ${error.message}`); - } - } } diff --git a/api/src/index.d.ts b/api/src/index.d.ts index b6c3f103a..f0ec76e83 100644 --- a/api/src/index.d.ts +++ b/api/src/index.d.ts @@ -27,7 +27,7 @@ declare module 'express-session' { passport?: { user?: SessionUser; }; - offline?: { + web?: { profile?: T; isSocket: boolean; messageQueue: any[]; @@ -40,7 +40,7 @@ declare module 'express-session' { passport?: { user?: SessionUser; }; - offline?: { + web?: { profile?: SubscriberStub; isSocket: boolean; messageQueue: any[]; diff --git a/api/src/nlp/nlp.module.ts b/api/src/nlp/nlp.module.ts index 8446276bc..342ffa1f5 100644 --- a/api/src/nlp/nlp.module.ts +++ b/api/src/nlp/nlp.module.ts @@ -9,7 +9,6 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; -import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; import { AttachmentModule } from '@/attachment/attachment.module'; @@ -32,7 +31,6 @@ import { NlpSampleService } from './services/nlp-sample.service'; import { NlpValueService } from './services/nlp-value.service'; import { NlpService } from './services/nlp.service'; -@InjectDynamicProviders('dist/extensions/**/*.nlp.helper.js') @Module({ imports: [ MongooseModule.forFeature([ diff --git a/api/src/plugins/base-block-plugin.ts b/api/src/plugins/base-block-plugin.ts index 47808fa3f..1a12ebf98 100644 --- a/api/src/plugins/base-block-plugin.ts +++ b/api/src/plugins/base-block-plugin.ts @@ -6,6 +6,8 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import path from 'path'; + import { Injectable } from '@nestjs/common'; import { Block, BlockFull } from '@/chat/schemas/block.schema'; @@ -17,6 +19,7 @@ import { PluginService } from './plugins.service'; import { PluginBlockTemplate, PluginEffects, + PluginName, PluginSetting, PluginType, } from './types'; @@ -29,16 +32,13 @@ export abstract class BaseBlockPlugin< public readonly settings: T; - constructor( - id: string, - settings: T, - pluginService: PluginService, - ) { - super(id, pluginService); - this.settings = settings; + constructor(name: PluginName, pluginService: PluginService) { + super(name, pluginService); + // eslint-disable-next-line @typescript-eslint/no-var-requires + this.settings = require(path.join(this.getPath(), 'settings')).default; } - template: PluginBlockTemplate; + abstract template: PluginBlockTemplate; effects?: PluginEffects; diff --git a/api/src/plugins/base-event-plugin.ts b/api/src/plugins/base-event-plugin.ts index 6a50cdd79..7e80a938b 100644 --- a/api/src/plugins/base-event-plugin.ts +++ b/api/src/plugins/base-event-plugin.ts @@ -10,13 +10,13 @@ import { Injectable } from '@nestjs/common'; import { BasePlugin } from './base-plugin.service'; import { PluginService } from './plugins.service'; -import { PluginType } from './types'; +import { PluginName, PluginType } from './types'; @Injectable() export abstract class BaseEventPlugin extends BasePlugin { public readonly type: PluginType = PluginType.event; - constructor(id: string, pluginService: PluginService) { - super(id, pluginService); + constructor(name: PluginName, pluginService: PluginService) { + super(name, pluginService); } } diff --git a/api/src/plugins/base-plugin.service.ts b/api/src/plugins/base-plugin.service.ts index 63c3be100..85986faa0 100644 --- a/api/src/plugins/base-plugin.service.ts +++ b/api/src/plugins/base-plugin.service.ts @@ -8,19 +8,24 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Extension } from '@/utils/generics/extension'; + import { PluginService } from './plugins.service'; -import { PluginType } from './types'; +import { PluginName, PluginType } from './types'; @Injectable() -export abstract class BasePlugin implements OnModuleInit { +export abstract class BasePlugin extends Extension implements OnModuleInit { public readonly type: PluginType; constructor( - public readonly id: string, + public readonly name: PluginName, private pluginService: PluginService, - ) {} + ) { + super(name); + } - onModuleInit() { - this.pluginService.setPlugin(this.type, this.id, this); + async onModuleInit() { + await super.onModuleInit(); + this.pluginService.setPlugin(this.type, this.name, this); } } diff --git a/api/src/plugins/base-storage-plugin.ts b/api/src/plugins/base-storage-plugin.ts index 8b1839c4e..7d3a5cfb9 100644 --- a/api/src/plugins/base-storage-plugin.ts +++ b/api/src/plugins/base-storage-plugin.ts @@ -13,14 +13,14 @@ import { Attachment } from '@/attachment/schemas/attachment.schema'; import { BasePlugin } from './base-plugin.service'; import { PluginService } from './plugins.service'; -import { PluginType } from './types'; +import { PluginName, PluginType } from './types'; @Injectable() export abstract class BaseStoragePlugin extends BasePlugin { public readonly type: PluginType = PluginType.storage; - constructor(id: string, pluginService: PluginService) { - super(id, pluginService); + constructor(name: PluginName, pluginService: PluginService) { + super(name, pluginService); } abstract fileExists(attachment: Attachment): Promise; diff --git a/api/src/plugins/plugins.module.ts b/api/src/plugins/plugins.module.ts index 2579f06f2..4dbf27082 100644 --- a/api/src/plugins/plugins.module.ts +++ b/api/src/plugins/plugins.module.ts @@ -20,7 +20,12 @@ import { ContentModel } from '@/cms/schemas/content.schema'; import { PluginService } from './plugins.service'; -@InjectDynamicProviders('dist/extensions/**/*.plugin.js') +@InjectDynamicProviders( + // Core & under dev plugins + 'dist/extensions/**/*.plugin.js', + // Installed plugins via npm + 'dist/.hexabot/plugins/**/*.plugin.js', +) @Global() @Module({ imports: [ diff --git a/api/src/plugins/plugins.service.spec.ts b/api/src/plugins/plugins.service.spec.ts index 31f15d6bb..bb9f42e4f 100644 --- a/api/src/plugins/plugins.service.spec.ts +++ b/api/src/plugins/plugins.service.spec.ts @@ -23,7 +23,7 @@ describe('PluginsService', () => { imports: [LoggerModule], }).compile(); pluginsService = module.get(PluginService); - module.get(DummyPlugin).onModuleInit(); + await module.get(DummyPlugin).onModuleInit(); }); afterAll(async () => { jest.clearAllMocks(); @@ -37,7 +37,7 @@ describe('PluginsService', () => { describe('getPlugin', () => { it('should return the required plugin', () => { - const result = pluginsService.getPlugin(PluginType.block, 'dummy'); + const result = pluginsService.getPlugin(PluginType.block, 'dummy-plugin'); expect(result).toBeInstanceOf(DummyPlugin); }); }); diff --git a/api/src/plugins/plugins.service.ts b/api/src/plugins/plugins.service.ts index d9858a9e4..1cb8170a1 100644 --- a/api/src/plugins/plugins.service.ts +++ b/api/src/plugins/plugins.service.ts @@ -10,7 +10,7 @@ import { Injectable } from '@nestjs/common'; import { BasePlugin } from './base-plugin.service'; import { PluginInstance } from './map-types'; -import { PluginType } from './types'; +import { PluginName, PluginType } from './types'; /** * @summary Service for managing and retrieving plugins. @@ -38,18 +38,18 @@ export class PluginService { constructor() {} /** - * Registers a plugin with a given key. + * Registers a plugin with a given name. * - * @param key The unique identifier for the plugin. + * @param name The unique identifier for the plugin. * @param plugin The plugin instance to register. */ - public setPlugin(type: PluginType, key: string, plugin: T) { + public setPlugin(type: PluginType, name: PluginName, plugin: T) { const registry = this.registry.get(type); - registry.set(key, plugin); + registry.set(name, plugin); } /** - * Retrieves all registered plugins as an array. + * Retrieves all registered plugins by as an array. * * @returns An array containing all the registered plugins. */ @@ -58,29 +58,39 @@ export class PluginService { 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 id The key used to register the plugin. + * @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, id: string) { + public getPlugin(type: PT, name: PluginName) { const registry = this.registry.get(type); - const plugin = registry.get(id); + const plugin = registry.get(name); return plugin ? (plugin as PluginInstance) : undefined; } /** * Finds a plugin by its internal `id` property. * - * @param pluginId The unique `id` of the plugin to find. + * @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, pluginId: string) { + public findPlugin(type: PT, name: PluginName) { return this.getAllByType(type).find((plugin) => { - return plugin.id === pluginId; + return plugin.name === name; }); } } diff --git a/api/src/plugins/types.ts b/api/src/plugins/types.ts index e557c999d..2dfae9dc9 100644 --- a/api/src/plugins/types.ts +++ b/api/src/plugins/types.ts @@ -6,11 +6,14 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import { ChannelEvent } from '@/channel/lib/EventWrapper'; import { BlockCreateDto } from '@/chat/dto/block.dto'; import { Block } from '@/chat/schemas/block.schema'; import { Conversation } from '@/chat/schemas/conversation.schema'; import { SettingCreateDto } from '@/setting/dto/setting.dto'; +export type PluginName = `${string}-plugin`; + export enum PluginType { event = 'event', block = 'block', @@ -19,7 +22,6 @@ export enum PluginType { export interface CustomBlocks {} -type ChannelEvent = any; type BlockAttrs = Partial & { name: string }; export type PluginSetting = Omit; diff --git a/api/src/setting/index.d.ts b/api/src/setting/index.d.ts index 90ea4a8a8..fab71fb7e 100644 --- a/api/src/setting/index.d.ts +++ b/api/src/setting/index.d.ts @@ -1,3 +1,11 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + import { SettingByType } from './schemas/types'; import { DEFAULT_SETTINGS } from './seeds/setting.seed-model'; diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index cd999cdf3..cb8b4c60d 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -13,7 +13,7 @@ export const DEFAULT_SETTINGS = [ { group: 'chatbot_settings', label: 'default_nlu_helper', - value: 'core-nlu', + value: 'core-nlu-helper', type: SettingType.select, config: { multiple: false, @@ -27,7 +27,7 @@ export const DEFAULT_SETTINGS = [ { group: 'chatbot_settings', label: 'default_llm_helper', - value: 'ollama', + value: 'ollama-helper', type: SettingType.select, config: { multiple: false, diff --git a/api/src/utils/generics/extension.ts b/api/src/utils/generics/extension.ts new file mode 100644 index 000000000..7b530f7fd --- /dev/null +++ b/api/src/utils/generics/extension.ts @@ -0,0 +1,51 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { promises as fs } from 'fs'; +import path from 'path'; + +import { OnModuleInit } from '@nestjs/common'; +import { I18nJsonLoader, I18nTranslation } from 'nestjs-i18n'; +import { Observable } from 'rxjs'; + +import { ExtensionName } from '../types/extension'; + +export abstract class Extension implements OnModuleInit { + private translations: I18nTranslation | Observable; + + constructor(public readonly name: ExtensionName) {} + + abstract getPath(): string; + + getName() { + return this.name; + } + + getNamespace() { + return this.name.replaceAll('-', '_') as HyphenToUnderscore; + } + + async onModuleInit() { + // Load i18n + const i18nPath = path.join(this.getPath(), 'i18n'); + try { + // Check if the i18n directory exists + await fs.access(i18nPath); + + // Load and merge translations + const i18nLoader = new I18nJsonLoader({ path: i18nPath }); + this.translations = await i18nLoader.load(); + } catch (error) { + // If the i18n folder does not exist or error in reading, skip this folder + } + } + + getTranslations() { + return this.translations; + } +} diff --git a/api/src/utils/test/dummy/dummy.plugin.ts b/api/src/utils/test/dummy/dummy.plugin.ts index 271343dd0..d729530d8 100644 --- a/api/src/utils/test/dummy/dummy.plugin.ts +++ b/api/src/utils/test/dummy/dummy.plugin.ts @@ -15,23 +15,27 @@ import { import { LoggerService } from '@/logger/logger.service'; import { BaseBlockPlugin } from '@/plugins/base-block-plugin'; import { PluginService } from '@/plugins/plugins.service'; -import { PluginSetting } from '@/plugins/types'; +import { PluginBlockTemplate, PluginSetting } from '@/plugins/types'; @Injectable() export class DummyPlugin extends BaseBlockPlugin { + template: PluginBlockTemplate = { name: 'Dummy Plugin' }; + constructor( pluginService: PluginService, private logger: LoggerService, ) { - super('dummy', [], pluginService); - - this.template = { name: 'Dummy Plugin' }; + super('dummy-plugin', pluginService); this.effects = { onStoreContextData: () => {}, }; } + getPath(): string { + return __dirname; + } + async process() { const envelope: StdOutgoingTextEnvelope = { format: OutgoingMessageFormat.text, diff --git a/api/src/utils/test/dummy/settings.ts b/api/src/utils/test/dummy/settings.ts new file mode 100644 index 000000000..d773e1360 --- /dev/null +++ b/api/src/utils/test/dummy/settings.ts @@ -0,0 +1,9 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +export default []; diff --git a/api/src/utils/test/fixtures/conversation.ts b/api/src/utils/test/fixtures/conversation.ts index 23b763419..548a84b20 100644 --- a/api/src/utils/test/fixtures/conversation.ts +++ b/api/src/utils/test/fixtures/conversation.ts @@ -10,8 +10,8 @@ import mongoose from 'mongoose'; import { ConversationCreateDto } from '@/chat/dto/conversation.dto'; import { - ConversationModel, Conversation, + ConversationModel, } from '@/chat/schemas/conversation.schema'; import { getFixturesWithDefaultValues } from '../defaultValues'; @@ -25,7 +25,7 @@ const conversations: ConversationCreateDto[] = [ sender: '0', active: true, context: { - channel: 'messenger', + channel: 'messenger-channel', text: 'Hi', payload: '', nlp: { @@ -60,7 +60,7 @@ const conversations: ConversationCreateDto[] = [ foreign_id: '', labels: [], assignedTo: null, - channel: { name: 'messenger' }, + channel: { name: 'messenger-channel' }, }, skip: {}, attempt: 0, @@ -71,7 +71,7 @@ const conversations: ConversationCreateDto[] = [ { sender: '1', context: { - channel: 'offline', + channel: 'web-channel', text: 'Hello', payload: '', nlp: { @@ -106,7 +106,7 @@ const conversations: ConversationCreateDto[] = [ foreign_id: '', labels: [], assignedTo: null, - channel: { name: 'offline' }, + channel: { name: 'web-channel' }, }, skip: {}, attempt: 0, diff --git a/api/src/utils/test/fixtures/label.ts b/api/src/utils/test/fixtures/label.ts index b32be697f..c38f5215b 100644 --- a/api/src/utils/test/fixtures/label.ts +++ b/api/src/utils/test/fixtures/label.ts @@ -9,7 +9,7 @@ import mongoose from 'mongoose'; import { LabelCreateDto } from '@/chat/dto/label.dto'; -import { LabelModel, Label } from '@/chat/schemas/label.schema'; +import { Label, LabelModel } from '@/chat/schemas/label.schema'; import { getFixturesWithDefaultValues } from '../defaultValues'; import { TFixturesDefaultValues } from '../types'; @@ -19,7 +19,7 @@ export const labels: LabelCreateDto[] = [ description: 'test description 1', label_id: { messenger: 'messenger', - offline: 'offline', + web: 'web', twitter: 'twitter', dimelo: 'dimelo', }, @@ -30,7 +30,7 @@ export const labels: LabelCreateDto[] = [ description: 'test description 2', label_id: { messenger: 'messenger', - offline: 'offline', + web: 'web', twitter: 'twitter', dimelo: 'dimelo', }, diff --git a/api/src/utils/test/fixtures/subscriber.ts b/api/src/utils/test/fixtures/subscriber.ts index 0df2eba15..605d75608 100644 --- a/api/src/utils/test/fixtures/subscriber.ts +++ b/api/src/utils/test/fixtures/subscriber.ts @@ -27,7 +27,7 @@ const subscribers: SubscriberCreateDto[] = [ gender: 'male', country: 'FR', channel: { - name: 'messenger', + name: 'messenger-channel', }, labels: [], assignedAt: null, @@ -35,7 +35,7 @@ const subscribers: SubscriberCreateDto[] = [ retainedFrom: new Date('2020-01-01T20:40:03.249Z'), }, { - foreign_id: 'foreign-id-offline-1', + foreign_id: 'foreign-id-web-1', first_name: 'Maynard', last_name: 'James Keenan', language: 'en', @@ -43,7 +43,7 @@ const subscribers: SubscriberCreateDto[] = [ gender: 'male', country: 'US', channel: { - name: 'offline', + name: 'web-channel', }, labels: [], assignedAt: null, @@ -51,7 +51,7 @@ const subscribers: SubscriberCreateDto[] = [ retainedFrom: new Date('2021-01-02T20:40:03.249Z'), }, { - foreign_id: 'foreign-id-offline-2', + foreign_id: 'foreign-id-web-2', first_name: 'Queen', last_name: 'Elisabeth', language: 'en', @@ -59,7 +59,7 @@ const subscribers: SubscriberCreateDto[] = [ gender: 'male', country: 'US', channel: { - name: 'offline', + name: 'web-channel', }, labels: [], assignedAt: null, @@ -75,7 +75,7 @@ const subscribers: SubscriberCreateDto[] = [ gender: 'male', country: 'US', channel: { - name: 'offline', + name: 'web-channel', }, labels: [], assignedAt: null, diff --git a/api/src/utils/test/mocks/conversation.ts b/api/src/utils/test/mocks/conversation.ts index b9e4c38b4..115b9d2ff 100644 --- a/api/src/utils/test/mocks/conversation.ts +++ b/api/src/utils/test/mocks/conversation.ts @@ -16,7 +16,7 @@ import { modelInstance } from './misc'; import { subscriberInstance } from './subscriber'; export const contextBlankInstance: Context = { - channel: 'offline', + channel: 'web-channel', text: '', payload: undefined, nlp: { entities: [] }, @@ -42,7 +42,7 @@ export const contextEmailVarInstance: Context = { }; export const contextGetStartedInstance: Context = { - channel: 'offline', + channel: 'web-channel', text: 'Get Started', payload: 'GET_STARTED', nlp: { entities: [] }, diff --git a/api/src/utils/test/mocks/subscriber.ts b/api/src/utils/test/mocks/subscriber.ts index ffe8b3247..8ec8ee83b 100644 --- a/api/src/utils/test/mocks/subscriber.ts +++ b/api/src/utils/test/mocks/subscriber.ts @@ -25,7 +25,7 @@ export const subscriberInstance: Subscriber = { lastvisit: new Date(), retainedFrom: new Date(), channel: { - name: 'offline', + name: 'web-channel', }, labels: [], ...modelInstance, diff --git a/api/src/utils/types/extension.ts b/api/src/utils/types/extension.ts new file mode 100644 index 000000000..07ac62d51 --- /dev/null +++ b/api/src/utils/types/extension.ts @@ -0,0 +1,13 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { ChannelName } from '@/channel/types'; +import { HelperName } from '@/helper/types'; +import { PluginName } from '@/plugins/types'; + +export type ExtensionName = ChannelName | HelperName | PluginName; diff --git a/api/src/websocket/websocket.gateway.spec.ts b/api/src/websocket/websocket.gateway.spec.ts index 690b3efed..c24407fcb 100644 --- a/api/src/websocket/websocket.gateway.spec.ts +++ b/api/src/websocket/websocket.gateway.spec.ts @@ -49,8 +49,8 @@ describe('WebsocketGateway', () => { ioClient = io('http://localhost:3000', { autoConnect: false, transports: ['websocket', 'polling'], - // path: '/socket.io/?EIO=4&transport=websocket&channel=offline', - query: { EIO: '4', transport: 'websocket', channel: 'offline' }, + // path: '/socket.io/?EIO=4&transport=websocket&channel=web-channel', + query: { EIO: '4', transport: 'websocket', channel: 'web-channel' }, }); app.listen(3000); diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index 371b0f906..f556c609f 100644 --- a/api/src/websocket/websocket.gateway.ts +++ b/api/src/websocket/websocket.gateway.ts @@ -209,6 +209,7 @@ export class WebsocketGateway this.logger.verbose('Client connected, attempting to load session.'); try { const { searchParams } = new URL(`ws://localhost${client.request.url}`); + if (client.request.headers.cookie) { const cookies = cookie.parse(client.request.headers.cookie); if (cookies && config.session.name in cookies) { @@ -223,7 +224,7 @@ export class WebsocketGateway 'Unable to load session, creating a new one ...', err, ); - if (searchParams.get('channel') === 'offline') { + if (searchParams.get('channel') === 'web-channel') { return this.createAndStoreSession(client, next); } else { return next(new Error('Unauthorized: Unknown session ID')); @@ -237,7 +238,7 @@ export class WebsocketGateway return next(new Error('Unable to parse session ID from cookie')); } } - } else if (searchParams.get('channel') === 'offline') { + } else if (searchParams.get('channel') === 'web-channel') { return this.createAndStoreSession(client, next); } else { return next(new Error('Unauthorized to connect to WS')); diff --git a/api/tsconfig.json b/api/tsconfig.json index b1b6499de..12289c34a 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -23,5 +23,11 @@ "@/*": ["src/*"] } }, - "include": ["src/**/*.ts", "src/**/*.json", "test/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.json", + "test/**/*.ts", + "src/.hexabot/**/*.ts", + "src/.hexabot/**/*.json" + ] } diff --git a/cli/src/index.ts b/cli/src/index.ts index 31620dfef..c1e86f1eb 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,12 +1,11 @@ #!/usr/bin/env node -import figlet from 'figlet'; -import { Command } from 'commander'; +import chalk from 'chalk'; import { execSync } from 'child_process'; +import { Command } from 'commander'; +import figlet from 'figlet'; import * as fs from 'fs'; import * as path from 'path'; -import chalk from 'chalk'; -import degit from 'degit'; console.log(figlet.textSync('Hexabot')); @@ -95,7 +94,7 @@ const dockerCompose = (args: string): void => { const dockerExec = ( container: string, command: string, - options?: string + options?: string, ): void => { try { execSync(`docker exec -it ${options} ${container} ${command}`, { @@ -179,7 +178,11 @@ program .description('Run database migrations') .action((args) => { const migrateArgs = args.join(' '); - dockerExec('api', `npm run migrate ${migrateArgs}`, '--user $(id -u):$(id -g)'); + dockerExec( + 'api', + `npm run migrate ${migrateArgs}`, + '--user $(id -u):$(id -g)', + ); }); program @@ -222,81 +225,6 @@ program dockerCompose(`${composeArgs} down -v`); }); -// Add install command to install extensions (e.g., channels, plugins) -program - .command('install') - .description('Install an extension for Hexabot') - .argument('', 'The type of extension (e.g., channel, plugin)') - .argument( - '', - 'GitHub repository for the extension (user/repo format)', - ) - .action(async (type, repository) => { - // Define the target folder based on the extension type - let targetFolder = ''; - switch (type) { - case 'channel': - targetFolder = 'api/src/extensions/channels/'; - break; - case 'plugin': - targetFolder = 'api/src/extensions/plugins/'; - break; - default: - console.error(chalk.red(`Unknown extension type: ${type}`)); - process.exit(1); - } - - // Get the last part of the repository name - const repoName = repository.split('/').pop(); - - // If the repo name starts with "hexabot--", remove that prefix - const extensionName = repoName.startsWith(`hexabot-${type}-`) - ? repoName.replace(`hexabot-${type}-`, '') - : repoName; - - const extensionPath = path.resolve( - process.cwd(), - targetFolder, - extensionName, - ); - - // Check if the extension folder already exists - if (fs.existsSync(extensionPath)) { - console.error( - chalk.red(`Error: Extension already exists at ${extensionPath}`), - ); - process.exit(1); - } - - try { - console.log( - chalk.cyan(`Fetching ${repository} into ${extensionPath}...`), - ); - - // Use degit to fetch the repository without .git history - const emitter = degit(repository); - await emitter.clone(extensionPath); - - console.log(chalk.cyan('Running npm install in the api/ folder...')); - // Run npm install in the api folder to install dependencies - execSync('npm run preinstall', { - cwd: path.resolve(process.cwd(), 'api'), - stdio: 'inherit', - }); - execSync('npm install', { - cwd: path.resolve(process.cwd(), 'api'), - stdio: 'inherit', - }); - - console.log( - chalk.green(`Successfully installed ${extensionName} as a ${type}.`), - ); - } catch (error) { - console.error(chalk.red('Error during installation:'), error); - process.exit(1); - } - }); - // Parse arguments program.parse(process.argv); diff --git a/docker/.env.example b/docker/.env.example index 71a3effb0..f9daf1a90 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -57,5 +57,5 @@ NEXT_PUBLIC_SSO_ENABLED=false # Widget APP_WIDGET_PORT=5173 REACT_APP_WIDGET_API_URL=http://${APP_DOMAIN}:${API_PORT} -REACT_APP_WIDGET_CHANNEL=offline +REACT_APP_WIDGET_CHANNEL=web-channel REACT_APP_WIDGET_TOKEN=token123 diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 5e55364ee..9811b52b0 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -216,18 +216,11 @@ "handled_by_me": "Assigned to me", "handled_by_chatbot": "Others", "settings": "Settings", - "facebook_settings": "Facebook", - "messenger": "Facebook Messenger", - "msbot": "Microsoft Bot Connector", - "offline": "Web Channel", - "twitter": "Twitter", - "dimelo": "Dimelo", "event_log": "Events Log", "log_entry": "Log entry", "dashboard": "Dashboard", "warning": "Warning", - "live_chat_tester": "Live Chat Tester", - "live-chat-tester": "Live Chat Tester" + "console": "Admin Chat Console" }, "label": { "terms": "I have read and approve the terms and conditions.", @@ -304,11 +297,6 @@ "user_location_zipcode": "Zipcode", "user_location_streetName": "Street Name", "from_channels": "Target channels", - "messenger": "Facebook/Messenger", - "msbot": "Microsoft Bot Connector", - "offline": "Canal Web", - "twitter": "Twitter", - "dimelo": "Dimelo", "simple_text": "Simple Text", "quick_replies": "Quick Replies", "buttons": "Buttons", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index e44ac4c08..d06616c75 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -216,18 +216,11 @@ "handled_by_me": "Assignés à moi", "handled_by_chatbot": "Autres", "settings": "Paramètres", - "facebook_settings": "Paramètres Facebook", - "messenger": "Facebook Messenger", - "msbot": "Microsoft Bot Connector", - "offline": "Canal Web", - "twitter": "Twitter", - "dimelo": "Dimelo", "event_log": "Journal des événements", "log_entry": "Journal des entrées", "dashboard": "Tableau de bord", "warning": "Avertissement", - "live_chat_tester": "Testeur Live Chat", - "live-chat-tester": "Testeur Live Chat" + "console": "Console de Chat Admin" }, "label": { "terms": "J'ai lu et approuvé les termes et conditions.", @@ -304,11 +297,6 @@ "user_location_zipcode": "Code postal", "user_location_streetName": "Adresse", "from_channels": "Cibler les canaux", - "messenger": "Facebook/Messenger", - "msbot": "Microsoft Bot Connector", - "offline": "Canal Web", - "twitter": "Twitter", - "dimelo": "Dimelo", "simple_text": "Texte simple", "quick_replies": "Réponses rapides", "buttons": "Boutons", diff --git a/frontend/src/app-components/widget/ChatWidget.tsx b/frontend/src/app-components/widget/ChatWidget.tsx index e8008c854..4f1771844 100644 --- a/frontend/src/app-components/widget/ChatWidget.tsx +++ b/frontend/src/app-components/widget/ChatWidget.tsx @@ -33,7 +33,7 @@ export const ChatWidget = () => { { - {t("title.live_chat_tester")} + {t("title.console")} ); diff --git a/frontend/src/components/visual-editor/CustomBlocks.tsx b/frontend/src/components/visual-editor/CustomBlocks.tsx index a2ebc1734..249cbe787 100644 --- a/frontend/src/components/visual-editor/CustomBlocks.tsx +++ b/frontend/src/components/visual-editor/CustomBlocks.tsx @@ -31,7 +31,9 @@ export const CustomBlocks = () => { {customBlocks?.map((customBlock) => ( { )} diff --git a/frontend/src/hooks/useRemoteI18n.ts b/frontend/src/hooks/useRemoteI18n.ts index 407e91bd0..c46d64375 100644 --- a/frontend/src/hooks/useRemoteI18n.ts +++ b/frontend/src/hooks/useRemoteI18n.ts @@ -22,14 +22,13 @@ export const useRemoteI18n = () => { const fetchRemoteI18n = async () => { try { const additionalTranslations = await apiClient.fetchRemoteI18n(); - // Assuming additionalTranslations is an object like { en: { translation: { key: 'value' } } } - Object.keys(additionalTranslations).forEach((lang) => { - Object.keys(additionalTranslations[lang]).forEach((namespace) => { + Object.keys(additionalTranslations).forEach((namespace) => { + Object.keys(additionalTranslations[namespace]).forEach((lang) => { i18n.addResourceBundle( lang, namespace, - additionalTranslations[lang][namespace], + additionalTranslations[namespace][lang], true, true, ); diff --git a/frontend/src/types/block.types.ts b/frontend/src/types/block.types.ts index 251f35d80..352117bf8 100644 --- a/frontend/src/types/block.types.ts +++ b/frontend/src/types/block.types.ts @@ -139,4 +139,6 @@ export interface ICustomBlockTemplateAttributes { // @TODO : templates doe not contain base schema attributes export interface ICustomBlockTemplate extends IBaseSchema, - OmitPopulate {} + OmitPopulate { + namespace: string; +} diff --git a/widget/README.md b/widget/README.md index 20eaeecc0..a25250ae3 100644 --- a/widget/README.md +++ b/widget/README.md @@ -61,7 +61,7 @@ Once the widget is built, you can easily embed it into any webpage. Here's an ex ReactDOM.render( el(HexabotWidget, { apiUrl: 'https://api.yourdomain.com', - channel: 'offline', + channel: 'web-channel', token: 'token123', }), domContainer, @@ -96,7 +96,7 @@ To prevent the website css from conflicting with the chat widget css, we can lev ReactDOM.render( React.createElement(HexabotWidget, { apiUrl: 'https://api.yourdomain.com', - channel: 'offline', + channel: 'web-channel', token: 'token123', }), shadowContainer, diff --git a/widget/public/index.html b/widget/public/index.html index 08131b046..b4e3f0156 100644 --- a/widget/public/index.html +++ b/widget/public/index.html @@ -34,7 +34,7 @@ ReactDOM.render( React.createElement(HexabotWidget, { apiUrl: 'http://localhost:4000', - channel: 'offline', + channel: 'web-channel', token: 'token123', }), shadowContainer, diff --git a/widget/src/constants/defaultConfig.ts b/widget/src/constants/defaultConfig.ts index e09133e18..a1711da21 100644 --- a/widget/src/constants/defaultConfig.ts +++ b/widget/src/constants/defaultConfig.ts @@ -8,7 +8,7 @@ export const DEFAULT_CONFIG = { apiUrl: process.env.REACT_APP_WIDGET_API_URL || 'http://localhost:4000', - channel: process.env.REACT_APP_WIDGET_CHANNEL || 'live-chat-tester', + channel: process.env.REACT_APP_WIDGET_CHANNEL || 'console-channel', token: process.env.REACT_APP_WIDGET_TOKEN || 'test', language: 'en', }; diff --git a/widget/src/main.tsx b/widget/src/main.tsx index f01b78a13..82c9e53ad 100644 --- a/widget/src/main.tsx +++ b/widget/src/main.tsx @@ -18,7 +18,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(