Merge pull request #257 from Hexastack/feat/refactor-extension

feat: refactor extensions as npm packages (be brave 1)
This commit is contained in:
Med Marrouchi
2024-10-23 06:31:36 +01:00
committed by GitHub
117 changed files with 941 additions and 957 deletions

View File

@@ -14,3 +14,4 @@ test
*.mock.ts
__mock__
__test__
.hexabot

1
api/.gitignore vendored
View File

@@ -1,3 +1,4 @@
.hexabot/
node_modules/
dist/
coverage/

View File

@@ -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

View File

@@ -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',
);

View File

@@ -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",

View File

@@ -108,13 +108,13 @@ export class AttachmentService extends BaseService<Attachment> {
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,
);

View File

@@ -23,7 +23,7 @@ export class ChannelController {
getChannels(): { name: string }[] {
return this.channelService.getAll().map((handler) => {
return {
name: handler.getChannel(),
name: handler.getName(),
};
});
}

View File

@@ -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);
}

View File

@@ -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],

View File

@@ -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<string, ChannelHandler<string>> = new Map();
private registry: Map<string, ChannelHandler<ChannelName>> = 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<T extends string, C extends ChannelHandler<T>>(
public setChannel<T extends ChannelName, C extends ChannelHandler<T>>(
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<T extends string, C extends ChannelHandler<T>>(
public getChannelHandler<T extends ChannelName, C extends ChannelHandler<T>>(
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<void> {
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);
}
}

View File

@@ -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(),

View File

@@ -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<N extends string = string> {
private readonly name: N;
export default abstract class ChannelHandler<
N extends ChannelName = ChannelName,
>
extends Extension
implements OnModuleInit
{
private readonly settings: ChannelSetting<N>[];
constructor(
name: N,
settings: ChannelSetting<N>[],
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<N>,
);
this.setup();
@@ -53,7 +59,7 @@ export default abstract class ChannelHandler<N extends string = string> {
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<N extends string = string> {
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<N>['group'];
}
/**
* Returns the channel's settings
* @returns Channel's settings
@@ -85,7 +75,7 @@ export default abstract class ChannelHandler<N extends string = string> {
async getSettings<S extends string = HyphenToUnderscore<N>>() {
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];
}
/**

View File

@@ -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',
},

View File

@@ -25,7 +25,7 @@ export const subscriberInstance: Subscriber = {
lastvisit: new Date(),
retainedFrom: new Date(),
channel: {
name: 'offline',
name: 'web-channel',
},
labels: [],
...modelInstance,

View File

@@ -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<N extends string = string> = Omit<
SettingCreateDto,
'group' | 'weight'

View File

@@ -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;

View File

@@ -173,7 +173,7 @@ describe('LabelController', () => {
name: 'LABEL_2',
label_id: {
messenger: 'messenger',
offline: 'offline',
web: 'web',
twitter: 'twitter',
dimelo: 'dimelo',
},

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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: [] },

View File

@@ -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<Block, BlockPopulate, BlockFull> {
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<Block, BlockPopulate, BlockFull> {
} else if (blockMessage && 'plugin' in blockMessage) {
const plugin = this.pluginService.findPlugin(
PluginType.block,
blockMessage.plugin,
blockMessage.plugin as PluginName,
);
// Process custom plugin block
try {

View File

@@ -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>(BotService);
blockService = module.get<BlockService>(BlockService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
handler = module.get<OfflineHandler>(OfflineHandler);
handler = module.get<WebChannelHandler>(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);

View File

@@ -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 {

View File

@@ -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();

View File

@@ -0,0 +1,3 @@
{
"console_channel": "Admin Chat Console"
}

View File

@@ -0,0 +1,3 @@
{
"console_channel": "Testeur Live Chat"
}

View File

@@ -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;
}
}

View File

@@ -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<typeof CONSOLE_CHANNEL_SETTINGS> {}
}
declare module '@nestjs/event-emitter' {
interface IHookExtensionsOperationMap {
[CONSOLE_CHANNEL_NAMESPACE]: TDefinition<
object,
SettingMapByType<typeof CONSOLE_CHANNEL_SETTINGS>
>;
}
}

View File

@@ -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"
}

View File

@@ -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<typeof LIVE_CHAT_TEST_CHANNEL_NAME>[];
] as const satisfies ChannelSetting<typeof CONSOLE_CHANNEL_NAME>[];

View File

@@ -1,3 +0,0 @@
{
"live_chat_tester": "Live Chat Tester"
}

View File

@@ -1,3 +0,0 @@
{
"live_chat_tester": "Testeur Live Chat"
}

View File

@@ -1,18 +0,0 @@
import {
DEFAULT_LIVE_CHAT_TEST_SETTINGS,
LIVE_CHAT_TEST_GROUP_NAME,
} from './settings';
declare global {
interface Settings
extends SettingTree<typeof DEFAULT_LIVE_CHAT_TEST_SETTINGS> {}
}
declare module '@nestjs/event-emitter' {
interface IHookExtensionsOperationMap {
[LIVE_CHAT_TEST_GROUP_NAME]: TDefinition<
object,
SettingMapByType<typeof DEFAULT_LIVE_CHAT_TEST_SETTINGS>
>;
}
}

View File

@@ -1,3 +0,0 @@
{
"offline": "Canal Web"
}

View File

@@ -1,3 +0,0 @@
{
"live_chat_tester": "Testeur Live Chat"
}

View File

@@ -1,14 +0,0 @@
import { DEFAULT_OFFLINE_SETTINGS, OFFLINE_GROUP_NAME } from './settings';
declare global {
interface Settings extends SettingTree<typeof DEFAULT_OFFLINE_SETTINGS> {}
}
declare module '@nestjs/event-emitter' {
interface IHookExtensionsOperationMap {
[OFFLINE_GROUP_NAME]: TDefinition<
object,
SettingMapByType<typeof DEFAULT_OFFLINE_SETTINGS>
>;
}
}

View File

@@ -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,
};

View File

@@ -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<Offline.IncomingTextMessage> =
{
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<Web.IncomingTextMessage> = {
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: {

View File

@@ -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>(SubscriberService);
handler = module.get<OfflineHandler>(OfflineHandler);
handler = module.get<WebChannelHandler>(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,

View File

@@ -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>(OfflineHandler);
handler = module.get<WebChannelHandler>(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([]);
});
});

View File

@@ -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<N> {
constructor(
name: N,
settings: ChannelSetting<N>[],
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<Offline.Message[]> {
const profile = req.session?.offline?.profile;
): Promise<Web.Message[]> {
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<Offline.Message[]> {
const profile = req.session?.offline?.profile;
): Promise<Web.Message[]> {
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<typeof OFFLINE_GROUP_NAME>();
const settings = await this.getSettings<typeof WEB_CHANNEL_NAMESPACE>();
// 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<SubscriberFull> {
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<Offline.IncomingAttachmentMessageData, 'url' | 'file'>,
upload: Omit<Web.IncomingAttachmentMessageData, 'url' | 'file'>,
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<void> {
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<WithUrl<Attachment>>,
_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<any, any>,
@@ -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<SubscriberCreateDto> {
async getUserData(event: WebEventWrapper): Promise<SubscriberCreateDto> {
return event.getSender() as SubscriberCreateDto;
}
}

View File

@@ -0,0 +1,3 @@
{
"web_channel": "Web Channel"
}

View File

@@ -0,0 +1,3 @@
{
"web_channel": "Canal Web"
}

View File

@@ -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;
}
}

View File

@@ -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<typeof DEFAULT_WEB_CHANNEL_SETTINGS> {}
}
declare module '@nestjs/event-emitter' {
interface IHookExtensionsOperationMap {
[WEB_CHANNEL_NAMESPACE]: TDefinition<
object,
SettingMapByType<typeof DEFAULT_WEB_CHANNEL_SETTINGS>
>;
}
}

View File

@@ -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"
}

View File

@@ -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<typeof OFFLINE_CHANNEL_NAME>[];
] as const satisfies ChannelSetting<typeof WEB_CHANNEL_NAME>[];

View File

@@ -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',

View File

@@ -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<Offline.IncomingTextMessage>;
raw: Web.IncomingMessage<Web.IncomingTextMessage>;
}
| {
eventType: StdEventType.message;
messageType:
| IncomingMessageType.postback
| IncomingMessageType.quick_reply;
raw: Offline.IncomingMessage<Offline.IncomingPayloadMessage>;
raw: Web.IncomingMessage<Web.IncomingPayloadMessage>;
}
| {
eventType: StdEventType.message;
messageType: IncomingMessageType.location;
raw: Offline.IncomingMessage<Offline.IncomingLocationMessage>;
raw: Web.IncomingMessage<Web.IncomingLocationMessage>;
}
| {
eventType: StdEventType.message;
messageType: IncomingMessageType.attachments;
raw: Offline.IncomingMessage<Offline.IncomingAttachmentMessage>;
raw: Web.IncomingMessage<Web.IncomingAttachmentMessage>;
};
export default class OfflineEventWrapper<
T extends BaseWebChannelHandler<string> = BaseWebChannelHandler<string>,
> extends EventWrapper<OfflineEventAdapter, Offline.Event> {
export default class WebEventWrapper<
T extends
BaseWebChannelHandler<ChannelName> = BaseWebChannelHandler<ChannelName>,
> extends EventWrapper<WebEventAdapter, Web.Event> {
/**
* 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;

View File

@@ -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) =>

View File

@@ -1,3 +1,3 @@
{
"core_nlu": "Core NLU Engine"
"core_nlu_helper": "Core NLU Engine"
}

View File

@@ -1,3 +1,3 @@
{
"core_nlu": "Core NLU Engine"
"core_nlu_helper": "Core NLU Engine"
}

View File

@@ -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<typeof CORE_NLU_HELPER_SETTINGS> {}
@@ -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<typeof CORE_NLU_HELPER_SETTINGS>
>;

View File

@@ -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;
}
/**

View File

@@ -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"
}
}

View File

@@ -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,

View File

@@ -1,3 +1,3 @@
{
"ollama": "Ollama"
"ollama_helper": "Ollama"
}

View File

@@ -1,3 +1,3 @@
{
"ollama": "Ollama"
"ollama_helper": "Ollama"
}

View File

@@ -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<typeof OLLAMA_HELPER_SETTINGS> {}
@@ -6,7 +14,7 @@ declare global {
declare module '@nestjs/event-emitter' {
interface IHookExtensionsOperationMap {
[OLLAMA_HELPER_GROUP]: TDefinition<
[OLLAMA_HELPER_NAMESPACE]: TDefinition<
object,
SettingMapByType<typeof OLLAMA_HELPER_SETTINGS>
>;

View File

@@ -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 });
}

View File

@@ -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

View File

@@ -1,3 +1,3 @@
{
"ollama": "Ollama"
"ollama_plugin": "Ollama Plugin"
}

View File

@@ -1,3 +1,3 @@
{
"ollama": "Ollama"
"ollama_plugin": "Ollama Plugin"
}

View File

@@ -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<typeof SETTINGS> {
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) {

View File

@@ -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"
}
}

View File

@@ -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',

View File

@@ -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],

View File

@@ -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<H extends BaseHelper<string>>(helper: H) {
public register<H extends BaseHelper>(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<T extends HelperType>(type: T, name: string) {
public get<T extends HelperType>(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) {

View File

@@ -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<N extends string = string> {
protected readonly name: N;
import { HelperName, HelperSetting, HelperType } from '../types';
export default abstract class BaseHelper<N extends HelperName = HelperName>
extends Extension
implements OnModuleInit
{
protected readonly settings: HelperSetting<N>[] = [];
protected abstract type: HelperType;
constructor(
name: N,
settings: HelperSetting<N>[],
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<N extends string = string> {
);
}
/**
* 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<N>['group'];
}
/**
* Get the helper's type
*
@@ -81,6 +68,6 @@ export default abstract class BaseHelper<N extends string = string> {
async getSettings<S extends string = HyphenToUnderscore<N>>() {
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];
}
}

View File

@@ -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<N> {
protected readonly type: HelperType = HelperType.LLM;
constructor(
name: N,
settings: HelperSetting<N>[],
settingService: SettingService,
helperService: HelperService,
logger: LoggerService,
) {
super(name, settings, settingService, helperService, logger);
super(name, settingService, helperService, logger);
}
/**

View File

@@ -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<N> {
protected readonly type: HelperType = HelperType.NLU;
constructor(
name: N,
settings: HelperSetting<N>[],
settingService: SettingService,
helperService: HelperService,
logger: LoggerService,
) {
super(name, settings, settingService, helperService, logger);
super(name, settingService, helperService, logger);
}
/**

View File

@@ -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> = T extends HelperType.LLM
? BaseLlmHelper<string>
? BaseLlmHelper<HelperName>
: T extends HelperType.NLU
? BaseNlpHelper<string>
? BaseNlpHelper<HelperName>
: BaseHelper;
export type HelperRegistry<H extends BaseHelper = BaseHelper> = Map<
@@ -40,7 +50,7 @@ export type HelperRegistry<H extends BaseHelper = BaseHelper> = Map<
Map<string, H>
>;
export type HelperSetting<N extends string = string> = Omit<
export type HelperSetting<N extends HelperName = HelperName> = Omit<
SettingCreateDto,
'group' | 'weight'
> & {

View File

@@ -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;
}, {});
}
}

View File

@@ -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<K = Record<string, unknown>>
extends NativeI18nService<K>
implements OnModuleInit
{
export class I18nService<
K = Record<string, unknown>,
> extends NativeI18nService<K> {
private dynamicTranslations: Record<string, Record<string, string>> = {};
private extensionTranslations: I18nTranslation = {};
onModuleInit() {
this.loadExtensionI18nTranslations();
}
getExtensionI18nTranslations() {
return this.extensionTranslations;
}
t<P extends Path<K> = any, R = PathValue<K, P>>(
key: P,
options?: TranslateOptions,
@@ -83,52 +66,4 @@ export class I18nService<K = Record<string, unknown>>
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}`);
}
}
}

4
api/src/index.d.ts vendored
View File

@@ -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[];

View File

@@ -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([

View File

@@ -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<BasePlugin>,
) {
super(id, pluginService);
this.settings = settings;
constructor(name: PluginName, pluginService: PluginService<BasePlugin>) {
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;

View File

@@ -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<BasePlugin>) {
super(id, pluginService);
constructor(name: PluginName, pluginService: PluginService<BasePlugin>) {
super(name, pluginService);
}
}

View File

@@ -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<BasePlugin>,
) {}
) {
super(name);
}
onModuleInit() {
this.pluginService.setPlugin(this.type, this.id, this);
async onModuleInit() {
await super.onModuleInit();
this.pluginService.setPlugin(this.type, this.name, this);
}
}

View File

@@ -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<BasePlugin>) {
super(id, pluginService);
constructor(name: PluginName, pluginService: PluginService<BasePlugin>) {
super(name, pluginService);
}
abstract fileExists(attachment: Attachment): Promise<boolean>;

View File

@@ -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: [

View File

@@ -23,7 +23,7 @@ describe('PluginsService', () => {
imports: [LoggerModule],
}).compile();
pluginsService = module.get<PluginService>(PluginService);
module.get<DummyPlugin>(DummyPlugin).onModuleInit();
await module.get<DummyPlugin>(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);
});
});

View File

@@ -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<T extends BasePlugin = BasePlugin> {
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<T extends BasePlugin = BasePlugin> {
return Array.from(registry.values()) as PluginInstance<PT>[];
}
/**
* 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<PT extends PluginType>(type: PT, id: string) {
public getPlugin<PT extends PluginType>(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<PT>) : 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<PT extends PluginType>(type: PT, pluginId: string) {
public findPlugin<PT extends PluginType>(type: PT, name: PluginName) {
return this.getAllByType(type).find((plugin) => {
return plugin.id === pluginId;
return plugin.name === name;
});
}
}

View File

@@ -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<BlockCreateDto> & { name: string };
export type PluginSetting = Omit<SettingCreateDto, 'weight'>;

View File

@@ -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';

View File

@@ -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,

View File

@@ -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<I18nTranslation>;
constructor(public readonly name: ExtensionName) {}
abstract getPath(): string;
getName() {
return this.name;
}
getNamespace<N extends ExtensionName = ExtensionName>() {
return this.name.replaceAll('-', '_') as HyphenToUnderscore<N>;
}
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;
}
}

View File

@@ -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<PluginSetting[]> {
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,

View File

@@ -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 [];

View File

@@ -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,

View File

@@ -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',
},

View File

@@ -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,

View File

@@ -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: [] },

View File

@@ -25,7 +25,7 @@ export const subscriberInstance: Subscriber = {
lastvisit: new Date(),
retainedFrom: new Date(),
channel: {
name: 'offline',
name: 'web-channel',
},
labels: [],
...modelInstance,

View File

@@ -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;

View File

@@ -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);

Some files were not shown because too many files have changed in this diff Show More