feat: refactor attachment storage to use helpers

This commit is contained in:
Mohamed Marrouchi
2025-01-20 09:59:03 +01:00
parent caae1344c8
commit 81aed2e5db
21 changed files with 515 additions and 344 deletions

View File

@@ -16,8 +16,13 @@ import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { Request } from 'express';
import LocalStorageHelper from '@/extensions/helpers/local-storage/index.helper';
import { HelperService } from '@/helper/helper.service';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingRepository } from '@/setting/repositories/setting.repository';
import { SettingModel } from '@/setting/schemas/setting.schema';
import { SettingSeeder } from '@/setting/seeds/setting.seed';
import { SettingService } from '@/setting/services/setting.service';
import { ModelRepository } from '@/user/repositories/model.repository';
import { PermissionRepository } from '@/user/repositories/permission.repository';
import { ModelModel } from '@/user/schemas/model.schema';
@@ -30,6 +35,7 @@ import {
attachmentFixtures,
installAttachmentFixtures,
} from '@/utils/test/fixtures/attachment';
import { installSettingFixtures } from '@/utils/test/fixtures/setting';
import {
closeInMongodConnection,
rootMongooseTestModule,
@@ -51,16 +57,23 @@ describe('AttachmentController', () => {
let attachmentController: AttachmentController;
let attachmentService: AttachmentService;
let attachmentToDelete: Attachment;
let helperService: HelperService;
let settingService: SettingService;
let loggerService: LoggerService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AttachmentController],
imports: [
rootMongooseTestModule(installAttachmentFixtures),
rootMongooseTestModule(async () => {
await installSettingFixtures();
await installAttachmentFixtures();
}),
MongooseModule.forFeature([
AttachmentModel,
PermissionModel,
ModelModel,
SettingModel,
]),
],
providers: [
@@ -68,11 +81,20 @@ describe('AttachmentController', () => {
AttachmentRepository,
PermissionService,
PermissionRepository,
SettingRepository,
ModelService,
ModelRepository,
LoggerService,
EventEmitter2,
PluginService,
SettingSeeder,
SettingService,
HelperService,
// {
// provide: HelperService,
// useValue: {
// getDefaultHelper: jest.fn(),
// },
// },
{
provide: CACHE_MANAGER,
useValue: {
@@ -89,6 +111,14 @@ describe('AttachmentController', () => {
attachmentToDelete = (await attachmentService.findOne({
name: 'store1.jpg',
}))!;
helperService = module.get<HelperService>(HelperService);
settingService = module.get<SettingService>(SettingService);
loggerService = module.get<LoggerService>(LoggerService);
helperService.register(
new LocalStorageHelper(settingService, helperService, loggerService),
);
});
afterAll(closeInMongodConnection);

View File

@@ -6,312 +6,87 @@
* 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 fs from 'fs';
import os from 'os';
import { join, normalize, resolve } from 'path';
import { Readable, Stream } from 'stream';
import {
Injectable,
NotFoundException,
Optional,
StreamableFile,
} from '@nestjs/common';
import fetch from 'node-fetch';
import sanitizeFilename from 'sanitize-filename';
import { Injectable, Optional, StreamableFile } from '@nestjs/common';
import { config } from '@/config';
import { HelperService } from '@/helper/helper.service';
import { HelperType } from '@/helper/types';
import { LoggerService } from '@/logger/logger.service';
import { PluginInstance } from '@/plugins/map-types';
import { PluginService } from '@/plugins/plugins.service';
import { PluginType } from '@/plugins/types';
import { BaseService } from '@/utils/generics/base-service';
import { AttachmentMetadataDto } from '../dto/attachment.dto';
import { AttachmentRepository } from '../repositories/attachment.repository';
import { Attachment } from '../schemas/attachment.schema';
import { AttachmentResourceRef } from '../types';
import {
fileExists,
generateUniqueFilename,
getStreamableFile,
} from '../utilities';
@Injectable()
export class AttachmentService extends BaseService<Attachment> {
private storagePlugin: PluginInstance<PluginType.storage> | null = null;
constructor(
readonly repository: AttachmentRepository,
private readonly logger: LoggerService,
@Optional() private readonly pluginService: PluginService,
@Optional() private readonly helperService: HelperService,
) {
super(repository);
}
/**
* A storage plugin is a alternative way to store files, instead of local filesystem, you can
* have a plugin that would store files in a 3rd party system (Minio, AWS S3, ...)
* Stores a file using the default storage helper and creates an attachment record.
*
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
* @returns A singleton instance of the storage plugin
*/
getStoragePlugin() {
if (!this.storagePlugin) {
const plugins = this.pluginService.getAllByType(PluginType.storage);
if (plugins.length === 1) {
this.storagePlugin = plugins[0];
} else if (plugins.length > 1) {
throw new Error(
'Multiple storage plugins are detected, please ensure only one is available',
);
}
}
return this.storagePlugin;
}
/**
* Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration.
* This method retrieves the default storage helper via the `HelperService` and
* delegates the file storage operation to it. The returned metadata is then used
* to create a new `Attachment` record in the database.
*
* @deprecated Use AttachmentService.download() instead
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
* @returns A `StreamableFile` containing the user's profile picture.
*/
async downloadProfilePic(
foreign_id: string,
): Promise<StreamableFile | undefined> {
if (this.getStoragePlugin()) {
try {
const pict = foreign_id + '.jpeg';
const picture =
await this.getStoragePlugin()?.downloadProfilePic?.(pict);
return picture;
} catch (err) {
this.logger.error('Error downloading profile picture', err);
throw new NotFoundException('Profile picture not found');
}
} else {
const path = resolve(
join(config.parameters.avatarDir, `${foreign_id}.jpeg`),
);
if (fs.existsSync(path)) {
const picturetream = fs.createReadStream(path);
return new StreamableFile(picturetream);
} else {
throw new NotFoundException('Profile picture not found');
}
}
}
/**
* Uploads a profile picture to either 3rd party storage system or locally based on the configuration.
*
* @deprecated use store() method instead
* @param res - The response object from which the profile picture will be buffered or piped.
* @param filename - The filename
*/
async uploadProfilePic(data: Buffer | fetch.Response, filename: string) {
if (this.getStoragePlugin()) {
// Upload profile picture
const picture = {
originalname: filename,
buffer: Buffer.isBuffer(data) ? data : await data.buffer(),
} as Express.Multer.File;
try {
await this.getStoragePlugin()?.uploadAvatar?.(picture);
this.logger.log(
`Profile picture uploaded successfully to ${
this.getStoragePlugin()?.name
}`,
);
} catch (err) {
this.logger.error(
`Error while uploading profile picture to ${
this.getStoragePlugin()?.name
}`,
err,
);
}
} else {
// Save profile picture locally
const dirPath = resolve(join(config.parameters.avatarDir, filename));
try {
if (Buffer.isBuffer(data)) {
await fs.promises.writeFile(dirPath, data);
} else {
const dest = fs.createWriteStream(dirPath);
data.body.pipe(dest);
}
this.logger.debug(
'Messenger Channel Handler : Profile picture fetched successfully',
);
} catch (err) {
this.logger.error(
'Messenger Channel Handler : Error while creating directory',
err,
);
}
}
}
/**
* Get the attachment root directory given the resource reference
*
* @param ref The attachment resource reference
* @returns The root directory path
*/
getRootDirByResourceRef(ref: AttachmentResourceRef) {
return ref === AttachmentResourceRef.SubscriberAvatar ||
ref === AttachmentResourceRef.UserAvatar
? config.parameters.avatarDir
: config.parameters.uploadDir;
}
/**
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
* Otherwise, uploads files to the local directory.
*
* @param file - The file
* @param metadata - The attachment metadata informations.
* @param rootDir - The root directory where attachment shoud be located.
* @returns A promise that resolves to an array of uploaded attachments.
* @param file - The file to be stored. This can be a buffer, a stream, a readable, or a file from an Express Multer upload.
* @param metadata - The metadata associated with the file, such as name, size, and type.
* @returns A promise resolving to the created `Attachment` record.
*/
async store(
file: Buffer | Stream | Readable | Express.Multer.File,
metadata: AttachmentMetadataDto,
): Promise<Attachment | null> {
if (this.getStoragePlugin()) {
const storedDto = await this.getStoragePlugin()?.store?.(file, metadata);
return storedDto ? await this.create(storedDto) : null;
} else {
const rootDir = this.getRootDirByResourceRef(metadata.resourceRef);
const uniqueFilename = generateUniqueFilename(metadata.name);
const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename)));
if (Buffer.isBuffer(file)) {
await fs.promises.writeFile(filePath, file);
} else if (file instanceof Readable || file instanceof Stream) {
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(filePath);
file.pipe(writeStream);
// @TODO: Calc size here?
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
} else {
if (file.path) {
// For example, if the file is an instance of `Express.Multer.File` (diskStorage case)
const srcFilePath = fs.realpathSync(resolve(file.path));
// Get the system's temporary directory in a cross-platform way
const tempDir = os.tmpdir();
const normalizedTempDir = normalize(tempDir);
if (!srcFilePath.startsWith(normalizedTempDir)) {
throw new Error('Invalid file path');
}
await fs.promises.copyFile(srcFilePath, filePath);
await fs.promises.unlink(srcFilePath);
} else {
await fs.promises.writeFile(filePath, file.buffer);
}
}
const location = filePath.replace(rootDir, '');
return await this.create({
...metadata,
location,
});
}
): Promise<Attachment> {
const storageHelper = await this.helperService.getDefaultHelper(
HelperType.STORAGE,
);
const dto = await storageHelper.store(file, metadata);
return await this.create(dto);
}
/**
* Downloads an attachment identified by the provided parameters.
* Downloads the specified attachment using the default storage helper.
*
* @param attachment - The attachment to download.
* @param rootDir - The root directory where attachment shoud be located.
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
* @param The attachment object containing the metadata required for the download.
* @returns A promise resolving to a `StreamableFile` instance of the downloaded attachment.
*/
async download(attachment: Attachment): Promise<StreamableFile> {
if (this.getStoragePlugin()) {
const streamableFile =
await this.getStoragePlugin()?.download(attachment);
if (!streamableFile) {
throw new NotFoundException('No file was found');
}
return streamableFile;
} else {
const rootDir = this.getRootDirByResourceRef(attachment.resourceRef);
const path = resolve(join(rootDir, attachment.location));
if (!fileExists(path)) {
throw new NotFoundException('No file was found');
}
const disposition = `attachment; filename="${encodeURIComponent(
attachment.name,
)}"`;
return getStreamableFile({
path,
options: {
type: attachment.type,
length: attachment.size,
disposition,
},
});
}
const storageHelper = await this.helperService.getDefaultHelper(
HelperType.STORAGE,
);
return storageHelper.download(attachment);
}
/**
* Downloads an attachment identified by the provided parameters as a Buffer.
* Reads the specified attachment as a buffer using the default storage helper.
*
* @param attachment - The attachment to download.
* @param rootDir - Root folder path where the attachment should be located.
* @returns A promise that resolves to a Buffer representing the attachment file.
* @param attachment - The attachment object containing the metadata required to locate the file.
* @returns A promise resolving to the file content as a `Buffer`, or `undefined` if the file cannot be read.
*/
async readAsBuffer(
attachment: Attachment,
rootDir = config.parameters.uploadDir,
): Promise<Buffer | undefined> {
if (this.getStoragePlugin()) {
return await this.getStoragePlugin()?.readAsBuffer?.(attachment);
} else {
const path = resolve(join(rootDir, attachment.location));
if (!fileExists(path)) {
throw new NotFoundException('No file was found');
}
return await fs.promises.readFile(path); // Reads the file content as a Buffer
}
async readAsBuffer(attachment: Attachment): Promise<Buffer | undefined> {
const storageHelper = await this.helperService.getDefaultHelper(
HelperType.STORAGE,
);
return storageHelper.readAsBuffer(attachment);
}
/**
* Returns an attachment identified by the provided parameters as a Stream.
* Reads the specified attachment as a stream using the default storage helper.
*
* @param attachment - The attachment to download.
* @param rootDir - Root folder path where the attachment should be located.
* @returns A promise that resolves to a Stream representing the attachment file.
* @param attachment - The attachment object containing the metadata required to locate the file.
* @returns A promise resolving to the file content as a `Stream`, or `undefined` if the file cannot be read.
*/
async readAsStream(
attachment: Attachment,
rootDir = config.parameters.uploadDir,
): Promise<Stream | undefined> {
if (this.getStoragePlugin()) {
return await this.getStoragePlugin()?.readAsStream?.(attachment);
} else {
const path = resolve(join(rootDir, attachment.location));
if (!fileExists(path)) {
throw new NotFoundException('No file was found');
}
return fs.createReadStream(path); // Reads the file content as a Buffer
}
async readAsStream(attachment: Attachment): Promise<Stream | undefined> {
const storageHelper = await this.helperService.getDefaultHelper(
HelperType.STORAGE,
);
return await storageHelper.readAsStream(attachment);
}
}

View File

@@ -0,0 +1,191 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import fs from 'fs';
import os from 'os';
import { join, normalize, resolve } from 'path';
import { Readable, Stream } from 'stream';
import {
Injectable,
NotFoundException,
OnModuleInit,
StreamableFile,
} from '@nestjs/common';
import sanitizeFilename from 'sanitize-filename';
import {
AttachmentCreateDto,
AttachmentMetadataDto,
} from '@/attachment/dto/attachment.dto';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { AttachmentResourceRef } from '@/attachment/types';
import {
fileExists,
generateUniqueFilename,
getStreamableFile,
} from '@/attachment/utilities';
import { config } from '@/config';
import { HelperService } from '@/helper/helper.service';
import BaseStorageHelper from '@/helper/lib/base-storage-helper';
import { LoggerService } from '@/logger/logger.service';
import { SettingService } from '@/setting/services/setting.service';
import { LOCAL_STORAGE_HELPER_NAME } from './settings';
@Injectable()
export default class LocalStorageHelper
extends BaseStorageHelper<typeof LOCAL_STORAGE_HELPER_NAME>
implements OnModuleInit
{
constructor(
settingService: SettingService,
helperService: HelperService,
logger: LoggerService,
) {
super(LOCAL_STORAGE_HELPER_NAME, settingService, helperService, logger);
}
getPath() {
return __dirname;
}
/**
* Get the attachment root directory given the resource reference
*
* @param ref The attachment resource reference
* @returns The root directory path
*/
private getRootDirByResourceRef(ref: AttachmentResourceRef) {
return ref === AttachmentResourceRef.SubscriberAvatar ||
ref === AttachmentResourceRef.UserAvatar
? config.parameters.avatarDir
: config.parameters.uploadDir;
}
/**
* Stores a attachment file to the local directory.
*
* @param file - The file
* @param metadata - The attachment metadata informations.
* @returns A promise that resolves to the uploaded attachment.
*/
async store(
file: Buffer | Stream | Readable | Express.Multer.File,
metadata: AttachmentMetadataDto,
): Promise<AttachmentCreateDto> {
const rootDir = this.getRootDirByResourceRef(metadata.resourceRef);
const uniqueFilename = generateUniqueFilename(metadata.name);
const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename)));
if (Buffer.isBuffer(file)) {
await fs.promises.writeFile(filePath, file);
} else if (file instanceof Readable || file instanceof Stream) {
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(filePath);
file.pipe(writeStream);
// @TODO: Calc size here?
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
} else {
if (file.path) {
// For example, if the file is an instance of `Express.Multer.File` (diskStorage case)
const srcFilePath = fs.realpathSync(resolve(file.path));
// Get the system's temporary directory in a cross-platform way
const tempDir = os.tmpdir();
const normalizedTempDir = normalize(tempDir);
if (!srcFilePath.startsWith(normalizedTempDir)) {
throw new Error('Invalid file path');
}
await fs.promises.copyFile(srcFilePath, filePath);
await fs.promises.unlink(srcFilePath);
} else {
await fs.promises.writeFile(filePath, file.buffer);
}
}
const location = filePath.replace(rootDir, '');
return {
...metadata,
location,
};
}
/**
* Downloads an attachment identified by the provided parameters.
*
* @param attachment - The attachment to download.
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
*/
async download(attachment: Attachment): Promise<StreamableFile> {
const rootDir = this.getRootDirByResourceRef(attachment.resourceRef);
const path = resolve(join(rootDir, attachment.location));
if (!fileExists(path)) {
throw new NotFoundException('No file was found');
}
const disposition = `attachment; filename="${encodeURIComponent(
attachment.name,
)}"`;
return getStreamableFile({
path,
options: {
type: attachment.type,
length: attachment.size,
disposition,
},
});
}
/**
* Returns an attachment identified by the provided parameters as a Buffer.
*
* @param attachment - The attachment to download.
* @returns A promise that resolves to a Buffer representing the attachment file.
*/
async readAsBuffer(attachment: Attachment): Promise<Buffer | undefined> {
const path = resolve(
join(
this.getRootDirByResourceRef(attachment.resourceRef),
attachment.location,
),
);
if (!fileExists(path)) {
throw new NotFoundException('No file was found');
}
return await fs.promises.readFile(path); // Reads the file content as a Buffer
}
/**
* Returns an attachment identified by the provided parameters as a Stream.
*
* @param attachment - The attachment to download.
* @returns A promise that resolves to a Stream representing the attachment file.
*/
async readAsStream(attachment: Attachment): Promise<Stream | undefined> {
const path = resolve(
join(
this.getRootDirByResourceRef(attachment.resourceRef),
attachment.location,
),
);
if (!fileExists(path)) {
throw new NotFoundException('No file was found');
}
return fs.createReadStream(path); // Reads the file content as a Buffer
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "hexabot-helper-local-storage",
"version": "2.2.0",
"description": "The default Hexabot Helper Extension for Hexabot to enable local storage for attachment files",
"dependencies": {},
"author": "Hexastack",
"license": "AGPL-3.0-only"
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { HelperSetting } from '@/helper/types';
export const LOCAL_STORAGE_HELPER_NAME = 'local-storage-helper';
export const LOCAL_STORAGE_HELPER_NAMESPACE = 'local-storage-helper';
export default [] as const satisfies HelperSetting<
typeof LOCAL_STORAGE_HELPER_NAME
>[];

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -108,6 +108,7 @@ export class HelperService {
/**
* Get default NLU helper.
*
* @deprecated Use getDefaultHelper() instead
* @returns - The helper
*/
async getDefaultNluHelper() {
@@ -128,6 +129,7 @@ export class HelperService {
/**
* Get default LLM helper.
*
* @deprecated Use getDefaultHelper() instead
* @returns - The helper
*/
async getDefaultLlmHelper() {
@@ -144,4 +146,31 @@ export class HelperService {
return defaultHelper;
}
/**
* Get default helper for a specific type.
*
* @param type - The type of the helper (e.g., NLU, LLM, STORAGE).
* @returns - The helper
*/
async getDefaultHelper<T extends HelperType>(type: T) {
if (type === HelperType.UTIL) {
throw new Error(
`Default helpers are not available for type: ${HelperType.UTIL}`,
);
}
const settings = await this.settingService.getSettings();
const defaultHelperName = settings.chatbot_settings[
`default_${type}_helper` as any
] as HelperName;
const defaultHelper = this.get<T>(type, defaultHelperName);
if (!defaultHelper) {
throw new Error(`Unable to find default ${type.toUpperCase()} helper`);
}
return defaultHelper;
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Readable, Stream } from 'stream';
import { StreamableFile } from '@nestjs/common';
import {
AttachmentCreateDto,
AttachmentMetadataDto,
} from '@/attachment/dto/attachment.dto';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { LoggerService } from '@/logger/logger.service';
import { SettingService } from '@/setting/services/setting.service';
import { HelperService } from '../helper.service';
import { HelperName, HelperType } from '../types';
import BaseHelper from './base-helper';
export default abstract class BaseStorageHelper<
N extends HelperName = HelperName,
> extends BaseHelper<N> {
protected readonly type: HelperType = HelperType.STORAGE;
constructor(
name: N,
settingService: SettingService,
helperService: HelperService,
logger: LoggerService,
) {
super(name, settingService, helperService, logger);
}
/**
* Uploads files to the server. If a storage helper is configured it uploads files accordingly.
* Otherwise, uploads files to the local directory.
*
* @param file - The file
* @param metadata - The attachment metadata informations.
* @returns A promise that resolves to an array of uploaded attachments.
*/
abstract store(
_file: Buffer | Stream | Readable | Express.Multer.File,
_metadata: AttachmentMetadataDto,
): Promise<AttachmentCreateDto>;
/**
* Downloads an attachment identified by the provided parameters.
*
* @param attachment - The attachment to download.
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
*/
abstract download(attachment: Attachment): Promise<StreamableFile>;
/**
* Downloads an attachment identified by the provided parameters as a Buffer.
*
* @param attachment - The attachment to download.
* @returns A promise that resolves to a Buffer representing the attachment file.
*/
abstract readAsBuffer(attachment: Attachment): Promise<Buffer | undefined>;
/**
* Returns an attachment identified by the provided parameters as a Stream.
*
* @param attachment - The attachment to download.
* @returns A promise that resolves to a Stream representing the attachment file.
*/
abstract readAsStream(attachment: Attachment): Promise<Stream | undefined>;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -12,6 +12,7 @@ import { HyphenToUnderscore } from '@/utils/types/extension';
import BaseHelper from './lib/base-helper';
import BaseLlmHelper from './lib/base-llm-helper';
import BaseNlpHelper from './lib/base-nlp-helper';
import BaseStorageHelper from './lib/base-storage-helper';
export namespace NLU {
export interface ParseEntity {
@@ -84,16 +85,20 @@ export namespace LLM {
export enum HelperType {
NLU = 'nlu',
LLM = 'llm',
STORAGE = 'storage',
UTIL = 'util',
}
export type HelperName = `${string}-helper`;
export type TypeOfHelper<T extends HelperType> = T extends HelperType.LLM
? BaseLlmHelper<HelperName>
: T extends HelperType.NLU
? BaseNlpHelper<HelperName>
: BaseHelper;
interface HelperTypeMap {
[HelperType.NLU]: BaseNlpHelper<HelperName>;
[HelperType.LLM]: BaseLlmHelper<HelperName>;
[HelperType.STORAGE]: BaseStorageHelper<HelperName>;
[HelperType.UTIL]: BaseHelper;
}
export type TypeOfHelper<T extends HelperType> = HelperTypeMap[T];
export type HelperRegistry<H extends BaseHelper = BaseHelper> = Map<
HelperType,

View File

@@ -772,6 +772,42 @@ const migrateAndPopulateAttachmentMessages = async ({
}
};
const addDefaultStorageHelper = async ({ logger }: MigrationServices) => {
const SettingModel = mongoose.model<Setting>(Setting.name, settingSchema);
try {
await SettingModel.create({
group: 'chatbot_settings',
label: 'default_storage_helper',
value: 'local-storage-helper',
type: SettingType.select,
config: {
multiple: false,
allowCreate: false,
entity: 'Helper',
idKey: 'name',
labelKey: 'name',
},
weight: 2,
});
logger.log('Successfuly added the default local storage helper setting');
} catch (err) {
logger.error('Unable to add the default local storage helper setting');
}
};
const removeDefaultStorageHelper = async ({ logger }: MigrationServices) => {
const SettingModel = mongoose.model<Setting>(Setting.name, settingSchema);
try {
await SettingModel.deleteOne({
group: 'chatbot_settings',
label: 'default_storage_helper',
});
logger.log('Successfuly removed the default local storage helper setting');
} catch (err) {
logger.error('Unable to remove the default local storage helper setting');
}
};
module.exports = {
async up(services: MigrationServices) {
await updateOldAvatarsPath(services);
@@ -784,6 +820,7 @@ module.exports = {
await populateSettingAttachments(services);
await populateUserAvatars(services);
await populateSubscriberAvatars(services);
await addDefaultStorageHelper(services);
return true;
},
async down(services: MigrationServices) {
@@ -792,6 +829,7 @@ module.exports = {
await restoreOldAvatarsPath(services);
await migrateAttachmentBlocks(MigrationAction.DOWN, services);
await migrateAttachmentContents(MigrationAction.DOWN, services);
await removeDefaultStorageHelper(services);
return true;
},
};

View File

@@ -1,53 +0,0 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Readable, Stream } from 'stream';
import { Injectable, StreamableFile } from '@nestjs/common';
import {
AttachmentCreateDto,
AttachmentMetadataDto,
} from '@/attachment/dto/attachment.dto';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { BasePlugin } from './base-plugin.service';
import { PluginService } from './plugins.service';
import { PluginName, PluginType } from './types';
@Injectable()
export abstract class BaseStoragePlugin extends BasePlugin {
public readonly type: PluginType = PluginType.storage;
constructor(name: PluginName, pluginService: PluginService<BasePlugin>) {
super(name, pluginService);
}
/** @deprecated use download() instead */
fileExists?(attachment: Attachment): Promise<boolean>;
/** @deprecated use store() instead */
upload?(file: Express.Multer.File): Promise<AttachmentCreateDto>;
/** @deprecated use store() instead */
uploadAvatar?(file: Express.Multer.File): Promise<any>;
abstract download(attachment: Attachment): Promise<StreamableFile>;
/** @deprecated use download() instead */
downloadProfilePic?(name: string): Promise<StreamableFile>;
readAsBuffer?(attachment: Attachment): Promise<Buffer>;
readAsStream?(attachment: Attachment): Promise<Stream>;
store?(
file: Buffer | Stream | Readable | Express.Multer.File,
metadata: AttachmentMetadataDto,
): Promise<Attachment>;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -9,13 +9,11 @@
import { BaseBlockPlugin } from './base-block-plugin';
import { BaseEventPlugin } from './base-event-plugin';
import { BasePlugin } from './base-plugin.service';
import { BaseStoragePlugin } from './base-storage-plugin';
import { PluginType } from './types';
const PLUGIN_TYPE_MAP = {
[PluginType.event]: BaseEventPlugin,
[PluginType.block]: BaseBlockPlugin,
[PluginType.storage]: BaseStoragePlugin,
};
export type PluginTypeMap = typeof PLUGIN_TYPE_MAP;

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -17,7 +17,6 @@ export type PluginName = `${string}-plugin`;
export enum PluginType {
event = 'event',
block = 'block',
storage = 'storage',
}
export interface CustomBlocks {}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -38,12 +38,26 @@ export const DEFAULT_SETTINGS = [
},
weight: 2,
},
{
group: 'chatbot_settings',
label: 'default_storage_helper',
value: 'local-storage-helper',
type: SettingType.select,
config: {
multiple: false,
allowCreate: false,
entity: 'Helper',
idKey: 'name',
labelKey: 'name',
},
weight: 3,
},
{
group: 'chatbot_settings',
label: 'global_fallback',
value: true,
type: SettingType.checkbox,
weight: 3,
weight: 4,
},
{
group: 'chatbot_settings',
@@ -58,7 +72,7 @@ export const DEFAULT_SETTINGS = [
idKey: 'id',
labelKey: 'name',
},
weight: 4,
weight: 5,
},
{
group: 'chatbot_settings',
@@ -68,7 +82,7 @@ export const DEFAULT_SETTINGS = [
"I'm really sorry but i don't quite understand what you are saying :(",
] as string[],
type: SettingType.multiple_text,
weight: 5,
weight: 6,
translatable: true,
},
{

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -13,6 +13,13 @@ import { SettingModel } from '@/setting/schemas/setting.schema';
import { SettingType } from '@/setting/schemas/types';
export const settingFixtures: SettingCreateDto[] = [
{
group: 'chatbot_settings',
label: 'default_storage_helper',
value: 'local-storage-helper',
type: SettingType.text,
weight: 1,
},
{
group: 'contact',
label: 'contact_email_recipient',