feat: add plugins settings i18n + typing

This commit is contained in:
Mohamed Marrouchi 2024-10-19 13:56:09 +01:00
parent 8d846186cc
commit 92bb4978c3
20 changed files with 107 additions and 67 deletions

View File

@ -24,7 +24,7 @@ export interface ChannelModuleOptions {
folder: string;
}
@InjectDynamicProviders('dist/**/*.channel.js')
@InjectDynamicProviders('dist/extensions/**/*.channel.js')
@Module({
controllers: [WebhookController, ChannelController],
providers: [ChannelService],

View File

@ -19,6 +19,7 @@ import { LoggerService } from '@/logger/logger.service';
import BaseNlpHelper from '@/nlp/lib/BaseNlpHelper';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingService } from '@/setting/services/setting.service';
import { hyphenToUnderscore } from '@/utils/helpers/misc';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
@ -56,7 +57,7 @@ export default abstract class ChannelHandler<N extends string = string> {
}
protected getGroup() {
return this.getChannel().replaceAll('-', '_') as ChannelSetting<N>['group'];
return hyphenToUnderscore(this.getChannel()) as ChannelSetting<N>['group'];
}
async setup() {

View File

@ -59,7 +59,7 @@ export class BlockController extends BaseController<
private readonly categoryService: CategoryService,
private readonly labelService: LabelService,
private readonly userService: UserService,
private pluginsService: PluginService<BaseBlockPlugin>,
private pluginsService: PluginService<BaseBlockPlugin<any>>,
) {
super(blockService);
}
@ -122,8 +122,7 @@ export class BlockController extends BaseController<
const plugins = this.pluginsService
.getAllByType(PluginType.block)
.map((p) => ({
title: p.title,
name: p.id,
id: p.id,
template: {
...p.template,
message: {

View File

@ -116,4 +116,23 @@ export class MessageService extends BaseService<
limit,
);
}
/**
* Retrieves the latest messages for a given subscriber
*
* @param subscriber - The subscriber whose message history is being retrieved.
* @param limit - The maximum number of messages to return (defaults to 5).
*
* @returns The message history since the specified date.
*/
async findLastMessages(subscriber: Subscriber, limit: number = 5) {
const lastMessages = await this.findPage(
{
$or: [{ sender: subscriber.id }, { recipient: subscriber.id }],
},
{ sort: ['createdAt', 'desc'], skip: 0, limit },
);
return lastMessages.reverse();
}
}

View File

View File

@ -22,6 +22,7 @@ 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>>
@ -84,41 +85,40 @@ export class I18nService<K = Record<string, unknown>>
}
async loadExtensionI18nTranslations() {
const extensionsDir = path.join(
__dirname,
'..',
'..',
'extensions',
'channels',
);
const baseDir = path.join(__dirname, '..', '..', 'extensions');
const extensionTypes = ['channels', 'plugins'];
try {
const extensionFolders = await fs.readdir(extensionsDir, {
withFileTypes: true,
});
for (const type of extensionTypes) {
const extensionsDir = path.join(baseDir, type);
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 extensionName = folder.name.replaceAll('-', '_');
try {
// Check if the i18n directory exists
await fs.access(i18nPath);
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] = {
[extensionName]: translations[lang],
};
} else {
this.extensionTranslations[lang][extensionName] =
translations[lang];
// 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) {
// If the i18n folder does not exist or error in reading, skip this folder
}
}
}

View File

@ -33,7 +33,7 @@ import { NlpSampleService } from './services/nlp-sample.service';
import { NlpValueService } from './services/nlp-value.service';
import { NlpService } from './services/nlp.service';
@InjectDynamicProviders('dist/**/*.nlp.helper.js')
@InjectDynamicProviders('dist/extensions/**/*.nlp.helper.js')
@Module({
imports: [
MongooseModule.forFeature([

View File

@ -22,17 +22,22 @@ import {
} from './types';
@Injectable()
export abstract class BaseBlockPlugin extends BasePlugin {
export abstract class BaseBlockPlugin<
T extends PluginSetting[],
> extends BasePlugin {
public readonly type: PluginType = PluginType.block;
constructor(id: string, pluginService: PluginService<BasePlugin>) {
public readonly settings: T;
constructor(
id: string,
settings: T,
pluginService: PluginService<BasePlugin>,
) {
super(id, pluginService);
this.settings = settings;
}
title: string;
settings: PluginSetting[];
template: PluginBlockTemplate;
effects?: PluginEffects;
@ -42,4 +47,11 @@ export abstract class BaseBlockPlugin extends BasePlugin {
context: Context,
convId?: string,
): Promise<StdOutgoingEnvelope>;
protected getArguments(block: Block) {
if ('args' in block.message) {
return block.message.args as SettingObject<T>;
}
throw new Error(`Block "${block.name}" does not have any arguments.`);
}
}

View File

@ -20,7 +20,7 @@ import { ContentModel } from '@/cms/schemas/content.schema';
import { PluginService } from './plugins.service';
@InjectDynamicProviders('dist/**/*.plugin.js')
@InjectDynamicProviders('dist/extensions/**/*.plugin.js')
@Global()
@Module({
imports: [

View File

@ -8,8 +8,8 @@
import { Test } from '@nestjs/testing';
import { DummyPlugin } from '@/extensions/plugins/dummy.plugin';
import { LoggerModule } from '@/logger/logger.module';
import { DummyPlugin } from '@/utils/test/dummy/dummy.plugin';
import { BaseBlockPlugin } from './base-block-plugin';
import { PluginService } from './plugins.service';

View File

@ -22,7 +22,7 @@ export interface CustomBlocks {}
type ChannelEvent = any;
type BlockAttrs = Partial<BlockCreateDto> & { name: string };
export type PluginSetting = SettingCreateDto;
export type PluginSetting = Omit<SettingCreateDto, 'weight'>;
export type PluginBlockTemplate = Omit<
BlockAttrs,

View File

@ -9,3 +9,7 @@
export const isEmpty = (value: string): boolean => {
return value === undefined || value === null || value === '';
};
export const hyphenToUnderscore = (str: string) => {
return str.replaceAll('-', '_');
};

View File

@ -15,18 +15,15 @@ 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';
@Injectable()
export class DummyPlugin extends BaseBlockPlugin {
export class DummyPlugin extends BaseBlockPlugin<PluginSetting[]> {
constructor(
pluginService: PluginService,
private logger: LoggerService,
) {
super('dummy', pluginService);
this.title = 'Dummy';
this.settings = [];
super('dummy', [], pluginService);
this.template = { name: 'Dummy Plugin' };

View File

@ -25,15 +25,17 @@ import { MIME_TYPES } from "@/utils/attachment";
interface RenderSettingInputProps {
setting: ISetting;
field: ControllerRenderProps<any, string>;
ns: string;
isDisabled?: (setting: ISetting) => boolean;
}
const SettingInput: React.FC<RenderSettingInputProps> = ({
setting,
field,
ns,
isDisabled = () => false,
}) => {
const { t } = useTranslate(setting.group);
const { t } = useTranslate(ns);
const label = t(`label.${setting.label}`, {
defaultValue: setting.label,
});

View File

@ -186,6 +186,7 @@ export const Settings = () => {
<SettingInput
setting={setting}
field={field}
ns={setting.group}
isDisabled={isDisabled}
/>
</FormControl>

View File

@ -30,8 +30,8 @@ export const CustomBlocks = () => {
<Grid container>
{customBlocks?.map((customBlock) => (
<Block
key={customBlock.name}
title={customBlock.title}
key={customBlock.id}
title={t(`title.${customBlock.id}`, { ns: customBlock.id })}
Icon={PluginIcon}
blockTemplate={customBlock.template}
name={customBlock.template.name}

View File

@ -109,16 +109,19 @@ export const OptionsForm = () => {
const { onChange, ...rest } = field;
return (
<AutoCompleteEntitySelect<ICustomBlockTemplate, "title">
searchFields={["title", "name"]}
<AutoCompleteEntitySelect<ICustomBlockTemplate, "id">
searchFields={["id"]}
entity={EntityType.CUSTOM_BLOCK}
format={Format.BASIC}
idKey="name"
labelKey="title"
idKey="id"
labelKey="id"
label={t("label.effects")}
multiple={true}
getOptionLabel={(option) => {
return t(`title.${option.id}`, { ns: option.id });
}}
onChange={(_e, selected) =>
onChange(selected.map(({ name }) => name))
onChange(selected.map(({ id }) => id))
}
preprocess={(options) => {
return options.filter(({ effects }) => effects.length > 0);

View File

@ -60,7 +60,11 @@ const PluginMessageForm = () => {
defaultValue={message.args?.[setting.label] || setting.value}
render={({ field }) => (
<FormControl fullWidth sx={{ paddingTop: ".75rem" }}>
<SettingInput setting={setting} field={field} />
<SettingInput
setting={setting}
field={field}
ns={message.plugin}
/>
</FormControl>
)}
/>

View File

@ -268,7 +268,7 @@ export const CustomBlockEntity = new schema.Entity(
EntityType.CUSTOM_BLOCK,
undefined,
{
idAttribute: ({ name }) => name,
idAttribute: ({ id }) => id,
},
);

View File

@ -11,15 +11,15 @@ import { EntityType, Format } from "@/services/types";
import { IBaseSchema, IFormat, OmitPopulate } from "./base.types";
import { ILabel } from "./label.types";
import {
StdOutgoingQuickRepliesMessage,
AttachmentForeignKey,
ContentOptions,
PayloadType,
StdOutgoingAttachmentMessage,
StdOutgoingButtonsMessage,
StdOutgoingListMessage,
StdOutgoingQuickRepliesMessage,
StdOutgoingTextMessage,
AttachmentForeignKey,
StdPluginMessage,
ContentOptions,
PayloadType,
} from "./message.types";
import { IUser } from "./user.types";
@ -132,8 +132,6 @@ export interface IBlockFull extends IBlockStub, IFormat<Format.FULL> {
}
export interface ICustomBlockTemplateAttributes {
title: string;
name: string;
template: IBlockAttributes;
effects: string[];
}