refactor(configuration): Setup configuration and menu to be reuseable

This commit is contained in:
Michel Fedde 2025-06-23 00:57:02 +02:00
parent 863ae3fab2
commit d46bbd84c5
21 changed files with 551 additions and 452 deletions

View file

@ -0,0 +1,71 @@
import {ConfigurationProvider} from "./ConfigurationProvider";
import {ConfigurationModel} from "../Database/Models/ConfigurationModel";
// @ts-expect-error set-path is provided
import setPath from 'object-path-set';
import deepmerge from "deepmerge";
// @ts-expect-error Any is fine
import {isPlainObject} from "is-plain-object";
import {Nullable} from "../types/Nullable";
import {ConfigurationTransformer, TransformerResults} from "./ConfigurationTransformer";
export class ConfigurationHandler<
TProviderModel extends ConfigurationModel = ConfigurationModel,
TRuntimeConfiguration extends object = Record<string, string>,
> {
public readonly transformer: ConfigurationTransformer
constructor(
private readonly provider: ConfigurationProvider<TProviderModel>
) {
this.transformer = provider.getTransformer();
}
public save(path: string, value: string): void {
const configuration = this.provider.get(path);
if (configuration) {
this.provider.save({
...configuration,
value
});
return;
}
this.provider.save({
key: path,
value
});
}
public getCompleteConfiguration(): TRuntimeConfiguration {
return deepmerge(
this.provider.defaults,
this.getCompleteDatabaseConfig(),
{
isMergeableObject: isPlainObject
}
)
}
public getConfigurationByPath(path: string): Nullable<TransformerResults> {
const configuration = this.provider.get(path);
if (!configuration) {
return;
}
return this.transformer.getValue(configuration);
}
private getCompleteDatabaseConfig(): Partial<TRuntimeConfiguration> {
const values = this.provider.getAll();
const configuration: Partial<TRuntimeConfiguration> = {};
values.forEach((configValue) => {
const value = this.transformer.getValue(configValue);
setPath(configuration, configValue.key, value);
})
return configuration;
}
}

View file

@ -0,0 +1,20 @@
import {ConfigurationModel} from "../Database/Models/ConfigurationModel";
import {Model} from "../Database/Models/Model";
import {ValueOf} from "../types/Class";
import {Nullable} from "../types/Nullable";
import {ConfigurationTransformer} from "./ConfigurationTransformer";
export interface ConfigurationProvider<
TProviderModel extends ConfigurationModel = ConfigurationModel,
TRuntimeConfiguration extends object = object
> {
get(path: string): Nullable<TProviderModel>;
getAll(): TProviderModel[];
get defaults(): TRuntimeConfiguration;
save(value: Omit<ConfigurationModel, "id"> & Partial<Model>): void;
getTransformer(): ConfigurationTransformer;
}

View file

@ -0,0 +1,55 @@
import {ChannelId} from "../types/DiscordTypes";
import {Nullable} from "../types/Nullable";
import {ArrayUtils} from "../Utilities/ArrayUtils";
import {ConfigurationModel} from "../Database/Models/ConfigurationModel";
export enum TransformerType {
Channel,
PermissionBoolean,
String,
Paragraph,
}
type ConfigurationTransformerItem = {
path: string[];
type: TransformerType,
}
export type TransformerResults =
ChannelId | boolean | string | null
export class ConfigurationTransformer {
constructor(
private readonly transformers: ConfigurationTransformerItem[]
) {
}
public getValue(configValue: ConfigurationModel): TransformerResults {
const transformerType = this.getTransformerType(configValue.key);
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can't find transformer for ${configValue.key}`);
}
switch (transformerType) {
case TransformerType.Channel:
return <ChannelId>configValue.value;
case TransformerType.PermissionBoolean:
return configValue.value === '1';
case TransformerType.Paragraph:
case TransformerType.String:
return configValue.value;
}
return null;
}
public getTransformerType(configKey: string): Nullable<TransformerType> {
const path = configKey.split('.');
return this.transformers.find(
transformer => {
return ArrayUtils.arraysEqual(transformer.path, path);
}
)?.type;
}
}

View file

@ -1,228 +0,0 @@
import {
AnyMenuItem, FieldMenuItem,
FieldMenuItemContext, FieldMenuItemSaveValue, MenuItem,
MenuItemType, PromptMenuItem,
RowBuilderFieldMenuItemContext
} from "../../Menu/MenuRenderer.types";
import {GroupConfigurationTransformers} from "./GroupConfigurationTransformers";
import {GroupConfigurationHandler} from "./GroupConfigurationHandler";
import {
channelMention,
ChannelSelectMenuBuilder,
ChannelType,
inlineCode,
italic,
Snowflake,
StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder,
TextInputStyle
} from "discord.js";
import {ChannelId} from "../../types/DiscordTypes";
import {MessageActionRowComponentBuilder} from "@discordjs/builders";
import {Prompt} from "../../Menu/Modals/Prompt";
export class ConfigurationMenuHandler {
constructor(
private readonly configuration: GroupConfigurationHandler,
private readonly transformer: GroupConfigurationTransformers
) {
}
public getMenuItems(): AnyMenuItem[] {
return [
{
traversalKey: "channels",
label: "Channels",
description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.",
type: MenuItemType.Collection,
children: [
{
traversalKey: "newPlaydates",
label: "New Playdates",
description: "Sets the channel, where the group gets notified, when new Playdates are set.",
type: MenuItemType.Field,
getCurrentValue: this.getChannelValue.bind(this),
getActionRowBuilder: this.getChannelMenuBuilder.bind(this),
setValue: this.setValue.bind(this)
},
{
traversalKey: "playdateReminders",
label: 'Playdate Reminders',
description: "Sets the channel, where the group gets reminded of upcoming playdates.",
type: MenuItemType.Field,
getCurrentValue: this.getChannelValue.bind(this),
getActionRowBuilder: this.getChannelMenuBuilder.bind(this),
setValue: this.setValue.bind(this)
}
]
},
{
traversalKey: "permissions",
label: "Permissions",
description: "Allows customization, how the members are allowed to interact with the data stored in the group.",
type: MenuItemType.Collection,
children: [
{
traversalKey: "allowMemberManagingPlaydates",
label: "Manage Playdates",
description: "Defines if the members are allowed to manage playdates like adding or deleting them.",
type: MenuItemType.Field,
getCurrentValue: this.getPermissionBooleanValue.bind(this),
getActionRowBuilder: this.getPermissionBooleanBuilder.bind(this),
setValue: this.setValue.bind(this)
}
]
},
{
traversalKey: "calendar",
label: "Calendar",
description: "Provides settings for the metadata contained in the playdate exports.",
type: MenuItemType.Collection,
children: [
this.createStringMenuItem({
traversalKey: "title",
label: "Title",
description: "Defines how the calendar entry should be called.",
}),
this.createTextareaMenuItem({
traversalKey: "description",
label: "Description",
description: "Sets the description for the calendar entry.",
}),
this.createStringMenuItem({
traversalKey: "location",
label: "Location",
description: "Sets the location where the calendar should point to."
}),
]
},
]
}
private createStringMenuItem(metadata: MenuItem): PromptMenuItem {
return {
...metadata,
type: MenuItemType.Prompt,
getCurrentValue: this.getStringValue.bind(this),
getActionRowBuilder: this.getStringBuilder.bind(this),
setValue: this.setValue.bind(this)
};
}
private createTextareaMenuItem(metadata: MenuItem): PromptMenuItem {
return {
...metadata,
type: MenuItemType.Prompt,
getCurrentValue: this.getStringValue.bind(this),
getActionRowBuilder: this.getTextareaBuilder.bind(this),
setValue: this.setValue.bind(this)
};
}
private getChannelValue(context: FieldMenuItemContext): string {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
if (!value) {
return inlineCode("None");
}
return channelMention(<ChannelId>value);
}
private getChannelMenuBuilder(context: FieldMenuItemContext): MessageActionRowComponentBuilder {
return new ChannelSelectMenuBuilder()
.setChannelTypes(ChannelType.GuildText)
.setPlaceholder("New Value");
}
private getLocaleValue(context: FieldMenuItemContext): string {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
if (!value) {
return inlineCode("Default");
}
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
return displaynames.of((<Intl.Locale>value)?.baseName) ?? "Unknown";
}
private getLocaleMenuBuilder(context: FieldMenuItemContext): MessageActionRowComponentBuilder {
const options = [
'en-US',
'fr-FR',
'it-IT',
'de-DE'
]
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
return new StringSelectMenuBuilder()
.setOptions(
options.map(intl => new StringSelectMenuOptionBuilder()
.setLabel(displaynames.of(intl) ?? '')
.setValue(intl)
)
)
}
private getPermissionBooleanValue(context: FieldMenuItemContext) {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
return value ? 'Allowed' : "Disallowed";
}
private getPermissionBooleanBuilder(context: FieldMenuItemContext) {
return new StringSelectMenuBuilder()
.setOptions(
[
{
label: "Allow",
value: "1"
},
{
label: "Disallow",
value: "0"
}
]
)
}
private getStringValue(context: FieldMenuItemContext): string {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (!value) {
return "";
}
if (typeof value !== 'string') {
throw new TypeError(`Value of type ${typeof value} can't be used for a string value!`)
}
return value;
}
private getStringBuilder(context: FieldMenuItemContext): TextInputBuilder {
return new TextInputBuilder()
.setStyle(TextInputStyle.Short)
.setMaxLength(100)
}
private getTextareaBuilder(context: FieldMenuItemContext): TextInputBuilder {
return new TextInputBuilder()
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(2048)
}
private setValue(value: FieldMenuItemSaveValue[]|string, context: FieldMenuItemContext): void {
const savedValue = typeof value !== 'string' ?
value.join('; ') :
value;
this.configuration.saveConfiguration(context.path.join('.'), savedValue);
}
}

View file

@ -1,80 +0,0 @@
import {RuntimeGroupConfiguration} from "./RuntimeGroupConfiguration";
import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository";
import {GroupModel} from "../../Database/Models/GroupModel";
import {GroupConfigurationResult, GroupConfigurationTransformers} from "./GroupConfigurationTransformers";
// @ts-expect-error set-path is provided
import setPath from 'object-path-set';
import deepmerge from "deepmerge";
import {Nullable} from "../../types/Nullable";
// @ts-expect-error Any is fine
import {isPlainObject} from "is-plain-object";
export class GroupConfigurationHandler {
static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = {
channels: null,
permissions: {
allowMemberManagingPlaydates: false
},
calendar: {
title: null,
description: null,
location: null
}
}
private readonly transformers: GroupConfigurationTransformers = new GroupConfigurationTransformers();
constructor(
private readonly repository: GroupConfigurationRepository,
private readonly group: GroupModel
) {
}
public saveConfiguration(path: string, value: string): void {
const configuration = this.repository.findConfigurationByPath(this.group, path);
if (configuration) {
this.repository.update(
{
...configuration,
value: value
}
)
return;
}
this.repository.create({
group: this.group,
key: path,
value: value,
});
}
public getConfiguration(): RuntimeGroupConfiguration {
return deepmerge(GroupConfigurationHandler.DEFAULT_CONFIGURATION, this.getDatabaseConfiguration(), {
isMergeableObject: isPlainObject
});
}
public getConfigurationByPath(path: string): Nullable<GroupConfigurationResult> {
const configuration = this.repository.findConfigurationByPath(this.group, path);
if (!configuration) {
return null;
}
return this.transformers.getValue(configuration);
}
private getDatabaseConfiguration(): Partial<RuntimeGroupConfiguration> {
const values = this.repository.findGroupConfigurations(this.group);
const configuration: Partial<RuntimeGroupConfiguration> = {};
values.forEach((configValue) => {
const value = this.transformers.getValue(configValue);
setPath(configuration, configValue.key, value);
})
return configuration;
}
}

View file

@ -0,0 +1,104 @@
import {ConfigurationProvider} from "../ConfigurationProvider";
import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel";
import {Nullable} from "../../types/Nullable";
import {ChannelId} from "../../types/DiscordTypes";
import { ConfigurationModel } from "../../Database/Models/ConfigurationModel";
import { Model } from "../../Database/Models/Model";
import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository";
import {GroupModel} from "../../Database/Models/GroupModel";
import {ConfigurationTransformer, TransformerType} from "../ConfigurationTransformer";
export type RuntimeGroupConfiguration = {
channels: Nullable<ChannelRuntimeGroupConfiguration>,
permissions: PermissionRuntimeGroupConfiguration,
calendar: CalendarRuntimeGroupConfiguration
};
export type ChannelRuntimeGroupConfiguration = {
newPlaydates: ChannelId,
playdateReminders: ChannelId
}
export type PermissionRuntimeGroupConfiguration = {
allowMemberManagingPlaydates: boolean
}
export type CalendarRuntimeGroupConfiguration = {
title: null | string,
description: null | string,
location: null | string
}
export type GroupConfigurationResult =
ChannelId | Intl.Locale | boolean | string | null
export class GroupConfigurationProvider implements ConfigurationProvider<
GroupConfigurationModel,
RuntimeGroupConfiguration
> {
constructor(
private readonly repository: GroupConfigurationRepository,
private readonly group: GroupModel
) {
}
get defaults(): RuntimeGroupConfiguration {
return {
channels: null,
permissions: {
allowMemberManagingPlaydates: false
},
calendar: {
title: null,
description: null,
location: null
}
}
}
get(path: string): Nullable<GroupConfigurationModel> {
return this.repository.findConfigurationByPath(this.group, path);
}
getAll(): GroupConfigurationModel[] {
return this.repository.findGroupConfigurations(this.group);
}
save(value: Omit<ConfigurationModel, "id"> & Partial<Model>): void {
if (value.id) {
// @ts-expect-error id is set, due to the check on line above
this.repository.update(value);
}
this.repository.create(value);
}
getTransformer(): ConfigurationTransformer {
return new ConfigurationTransformer(
[
{
path: ['channels', 'newPlaydates'],
type: TransformerType.Channel,
},
{
path: ['channels', 'playdateReminders'],
type: TransformerType.Channel,
},
{
path: ['permissions', 'allowMemberManagingPlaydates'],
type: TransformerType.PermissionBoolean
},
{
path: ['calendar', 'title'],
type: TransformerType.String
},
{
path: ['calendar', 'description'],
type: TransformerType.Paragraph,
},
{
path: ['calendar', 'location'],
type: TransformerType.String
}
]
)
}
}

View file

@ -1,77 +0,0 @@
import {ChannelId} from "../../types/DiscordTypes";
import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel";
import {Nullable} from "../../types/Nullable";
import {ArrayUtils} from "../../Utilities/ArrayUtils";
export enum TransformerType {
Locale,
Channel,
PermissionBoolean,
String
}
type GroupConfigurationTransformer = {
path: string[];
type: TransformerType,
}
export type GroupConfigurationResult =
ChannelId | Intl.Locale | boolean | string | null
export class GroupConfigurationTransformers {
static TRANSFORMERS: GroupConfigurationTransformer[] = [
{
path: ['channels', 'newPlaydates'],
type: TransformerType.Channel,
},
{
path: ['channels', 'playdateReminders'],
type: TransformerType.Channel,
},
{
path: ['permissions', 'allowMemberManagingPlaydates'],
type: TransformerType.PermissionBoolean
},
{
path: ['calendar', 'title'],
type: TransformerType.String
},
{
path: ['calendar', 'description'],
type: TransformerType.String,
},
{
path: ['calendar', 'location'],
type: TransformerType.String
}
];
public getValue(configValue: GroupConfigurationModel): GroupConfigurationResult {
const transformerType = this.getTransformerType(configValue.key);
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can't find transformer for ${configValue.key}`);
}
switch (transformerType) {
case TransformerType.Locale:
return new Intl.Locale(configValue.value)
case TransformerType.Channel:
return <ChannelId>configValue.value;
case TransformerType.PermissionBoolean:
return configValue.value === '1';
case TransformerType.String:
return configValue.value;
}
}
public getTransformerType(configKey: string): Nullable<TransformerType> {
const path = configKey.split('.');
return GroupConfigurationTransformers.TRANSFORMERS.find(
transformer => {
return ArrayUtils.arraysEqual(transformer.path, path);
}
)?.type;
}
}

View file

@ -1,23 +0,0 @@
import {ChannelId} from "../../types/DiscordTypes";
import {Nullable} from "../../types/Nullable";
export type RuntimeGroupConfiguration = {
channels: Nullable<ChannelRuntimeGroupConfiguration>,
permissions: PermissionRuntimeGroupConfiguration,
calendar: CalendarRuntimeGroupConfiguration
};
export type ChannelRuntimeGroupConfiguration = {
newPlaydates: ChannelId,
playdateReminders: ChannelId
}
export type PermissionRuntimeGroupConfiguration = {
allowMemberManagingPlaydates: boolean
}
export type CalendarRuntimeGroupConfiguration = {
title: null|string,
description: null|string,
location: null|string
}

View file

@ -0,0 +1,177 @@
import {ConfigurationHandler} from "./ConfigurationHandler";
import {
AnyMenuItem,
CollectionMenuItem,
FieldMenuItem, FieldMenuItemContext, FieldMenuItemSaveValue,
MenuItem,
MenuItemType,
PromptMenuItem
} from "../Menu/MenuRenderer.types";
import {TraversalPath} from "../Menu/MenuTraversal.types";
import {
channelMention,
ChannelSelectMenuBuilder,
ChannelType,
inlineCode,
italic,
StringSelectMenuBuilder, TextInputBuilder, TextInputStyle
} from "discord.js";
import {ChannelId} from "../types/DiscordTypes";
import {MessageActionRowComponentBuilder} from "@discordjs/builders";
import {TransformerType} from "./ConfigurationTransformer";
type FieldHandlerMenuItem = MenuItem & (Partial<PromptMenuItem> | Partial<FieldMenuItem>);
type HandlerMenuItem =
FieldHandlerMenuItem | CollectionMenuItem<HandlerMenuItem>
export class MenuHandler {
constructor(
private readonly config: ConfigurationHandler,
) {
}
public fillMenuItems(
menuItems: HandlerMenuItem[]
): AnyMenuItem[] {
const handler = this;
function resolve(path: TraversalPath, menuItem: HandlerMenuItem): AnyMenuItem {
path = [...path, menuItem.traversalKey];
if (menuItem.type === MenuItemType.Collection) {
menuItem.children = menuItem.children.map((nextMenuItem) => resolve(path, nextMenuItem))
return <CollectionMenuItem>menuItem;
}
const transformerType = handler.config.transformer.getTransformerType(path.join('.'));
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can't resolve type for '${path.join('.')}'`);
}
menuItem.type = handler.findType(transformerType);
menuItem.setValue = handler.setValue.bind(handler);
menuItem.getCurrentValue = handler.findCurrentValueMethod(transformerType);
menuItem.getActionRowBuilder = handler.findActionRowBuilderMethod(transformerType);
return <AnyMenuItem>menuItem;
}
return menuItems.map((menuItem) => resolve([], menuItem))
}
private findType(transformer: TransformerType): MenuItemType.Field | MenuItemType.Prompt
{
switch (transformer) {
case TransformerType.String:
case TransformerType.Paragraph:
return MenuItemType.Prompt;
default:
return MenuItemType.Field;
}
}
private findCurrentValueMethod(transformer: TransformerType): (context: FieldMenuItemContext) => string
{
switch (transformer) {
case TransformerType.Channel:
return this.getChannelValue.bind(this);
case TransformerType.PermissionBoolean:
return this.getPermissionBooleanValue.bind(this);
case TransformerType.String:
case TransformerType.Paragraph:
return this.getStringValue.bind(this);
}
}
private findActionRowBuilderMethod(transformer: TransformerType):
((context: FieldMenuItemContext) => MessageActionRowComponentBuilder) | ((context: FieldMenuItemContext) => TextInputBuilder)
{
switch (transformer) {
case TransformerType.Channel:
return this.getChannelMenuBuilder.bind(this);
case TransformerType.PermissionBoolean:
return this.getPermissionBooleanBuilder.bind(this);
case TransformerType.String:
return this.getStringBuilder.bind(this);
case TransformerType.Paragraph:
return this.getTextareaBuilder.bind(this);
}
}
private getChannelValue(context: FieldMenuItemContext): string {
const value = this.config.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
if (!value) {
return inlineCode("None");
}
return channelMention(<ChannelId>value);
}
private getChannelMenuBuilder(): MessageActionRowComponentBuilder {
return new ChannelSelectMenuBuilder()
.setChannelTypes(ChannelType.GuildText)
.setPlaceholder("New Value");
}
private getPermissionBooleanValue(context: FieldMenuItemContext) {
const value = this.config.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
return value ? 'Allowed' : "Disallowed";
}
private getPermissionBooleanBuilder() {
return new StringSelectMenuBuilder()
.setOptions(
[
{
label: "Allow",
value: "1"
},
{
label: "Disallow",
value: "0"
}
]
)
}
private getStringValue(context: FieldMenuItemContext): string {
const value = this.config.getConfigurationByPath(context.path.join('.'));
if (!value) {
return "";
}
if (typeof value !== 'string') {
throw new TypeError(`Value of type ${typeof value} can't be used for a string value!`)
}
return value;
}
private getStringBuilder(): TextInputBuilder {
return new TextInputBuilder()
.setStyle(TextInputStyle.Short)
.setMaxLength(100)
}
private getTextareaBuilder(): TextInputBuilder {
return new TextInputBuilder()
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(2048)
}
private setValue(value: FieldMenuItemSaveValue[]|string, context: FieldMenuItemContext): void {
const savedValue = typeof value !== 'string' ?
value.join('; ') :
value;
this.config.save(context.path.join('.'), savedValue);
}
}