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; folder: string;
} }
@InjectDynamicProviders('dist/**/*.channel.js') @InjectDynamicProviders('dist/extensions/**/*.channel.js')
@Module({ @Module({
controllers: [WebhookController, ChannelController], controllers: [WebhookController, ChannelController],
providers: [ChannelService], providers: [ChannelService],

View File

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

View File

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

View File

@ -116,4 +116,23 @@ export class MessageService extends BaseService<
limit, 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 { config } from '@/config';
import { Translation } from '@/i18n/schemas/translation.schema'; import { Translation } from '@/i18n/schemas/translation.schema';
import { hyphenToUnderscore } from '@/utils/helpers/misc';
@Injectable() @Injectable()
export class I18nService<K = Record<string, unknown>> export class I18nService<K = Record<string, unknown>>
@ -84,41 +85,40 @@ export class I18nService<K = Record<string, unknown>>
} }
async loadExtensionI18nTranslations() { async loadExtensionI18nTranslations() {
const extensionsDir = path.join( const baseDir = path.join(__dirname, '..', '..', 'extensions');
__dirname, const extensionTypes = ['channels', 'plugins'];
'..',
'..',
'extensions',
'channels',
);
try { try {
const extensionFolders = await fs.readdir(extensionsDir, { for (const type of extensionTypes) {
withFileTypes: true, const extensionsDir = path.join(baseDir, type);
}); const extensionFolders = await fs.readdir(extensionsDir, {
withFileTypes: true,
});
for (const folder of extensionFolders) { for (const folder of extensionFolders) {
if (folder.isDirectory()) { if (folder.isDirectory()) {
const i18nPath = path.join(extensionsDir, folder.name, 'i18n'); const i18nPath = path.join(extensionsDir, folder.name, 'i18n');
const extensionName = folder.name.replaceAll('-', '_'); const namespace = hyphenToUnderscore(folder.name);
try { try {
// Check if the i18n directory exists // Check if the i18n directory exists
await fs.access(i18nPath); await fs.access(i18nPath);
// Load and merge translations // Load and merge translations
const i18nLoader = new I18nJsonLoader({ path: i18nPath }); const i18nLoader = new I18nJsonLoader({ path: i18nPath });
const translations = await i18nLoader.load(); const translations = await i18nLoader.load();
for (const lang in translations) { for (const lang in translations) {
if (!this.extensionTranslations[lang]) { if (!this.extensionTranslations[lang]) {
this.extensionTranslations[lang] = { this.extensionTranslations[lang] = {
[extensionName]: translations[lang], [namespace]: translations[lang],
}; };
} else { } else {
this.extensionTranslations[lang][extensionName] = this.extensionTranslations[lang][namespace] =
translations[lang]; 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 { NlpValueService } from './services/nlp-value.service';
import { NlpService } from './services/nlp.service'; import { NlpService } from './services/nlp.service';
@InjectDynamicProviders('dist/**/*.nlp.helper.js') @InjectDynamicProviders('dist/extensions/**/*.nlp.helper.js')
@Module({ @Module({
imports: [ imports: [
MongooseModule.forFeature([ MongooseModule.forFeature([

View File

@ -22,17 +22,22 @@ import {
} from './types'; } from './types';
@Injectable() @Injectable()
export abstract class BaseBlockPlugin extends BasePlugin { export abstract class BaseBlockPlugin<
T extends PluginSetting[],
> extends BasePlugin {
public readonly type: PluginType = PluginType.block; 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); super(id, pluginService);
this.settings = settings;
} }
title: string;
settings: PluginSetting[];
template: PluginBlockTemplate; template: PluginBlockTemplate;
effects?: PluginEffects; effects?: PluginEffects;
@ -42,4 +47,11 @@ export abstract class BaseBlockPlugin extends BasePlugin {
context: Context, context: Context,
convId?: string, convId?: string,
): Promise<StdOutgoingEnvelope>; ): 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'; import { PluginService } from './plugins.service';
@InjectDynamicProviders('dist/**/*.plugin.js') @InjectDynamicProviders('dist/extensions/**/*.plugin.js')
@Global() @Global()
@Module({ @Module({
imports: [ imports: [

View File

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

View File

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

View File

@ -9,3 +9,7 @@
export const isEmpty = (value: string): boolean => { export const isEmpty = (value: string): boolean => {
return value === undefined || value === null || value === ''; 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 { LoggerService } from '@/logger/logger.service';
import { BaseBlockPlugin } from '@/plugins/base-block-plugin'; import { BaseBlockPlugin } from '@/plugins/base-block-plugin';
import { PluginService } from '@/plugins/plugins.service'; import { PluginService } from '@/plugins/plugins.service';
import { PluginSetting } from '@/plugins/types';
@Injectable() @Injectable()
export class DummyPlugin extends BaseBlockPlugin { export class DummyPlugin extends BaseBlockPlugin<PluginSetting[]> {
constructor( constructor(
pluginService: PluginService, pluginService: PluginService,
private logger: LoggerService, private logger: LoggerService,
) { ) {
super('dummy', pluginService); super('dummy', [], pluginService);
this.title = 'Dummy';
this.settings = [];
this.template = { name: 'Dummy Plugin' }; this.template = { name: 'Dummy Plugin' };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -268,7 +268,7 @@ export const CustomBlockEntity = new schema.Entity(
EntityType.CUSTOM_BLOCK, EntityType.CUSTOM_BLOCK,
undefined, 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 { IBaseSchema, IFormat, OmitPopulate } from "./base.types";
import { ILabel } from "./label.types"; import { ILabel } from "./label.types";
import { import {
StdOutgoingQuickRepliesMessage, AttachmentForeignKey,
ContentOptions,
PayloadType,
StdOutgoingAttachmentMessage, StdOutgoingAttachmentMessage,
StdOutgoingButtonsMessage, StdOutgoingButtonsMessage,
StdOutgoingListMessage, StdOutgoingListMessage,
StdOutgoingQuickRepliesMessage,
StdOutgoingTextMessage, StdOutgoingTextMessage,
AttachmentForeignKey,
StdPluginMessage, StdPluginMessage,
ContentOptions,
PayloadType,
} from "./message.types"; } from "./message.types";
import { IUser } from "./user.types"; import { IUser } from "./user.types";
@ -132,8 +132,6 @@ export interface IBlockFull extends IBlockStub, IFormat<Format.FULL> {
} }
export interface ICustomBlockTemplateAttributes { export interface ICustomBlockTemplateAttributes {
title: string;
name: string;
template: IBlockAttributes; template: IBlockAttributes;
effects: string[]; effects: string[];
} }