From d46bbd84c51dc9bc46417f1a9af434448f6bde82 Mon Sep 17 00:00:00 2001 From: Michel Fedde Date: Mon, 23 Jun 2025 00:57:02 +0200 Subject: [PATCH] refactor(configuration): Setup configuration and menu to be reuseable --- source/Configuration/ConfigurationHandler.ts | 71 ++++++ source/Configuration/ConfigurationProvider.ts | 20 ++ .../Configuration/ConfigurationTransformer.ts | 55 +++++ .../Groups/ConfigurationMenuHandler.ts | 228 ------------------ .../Groups/GroupConfigurationHandler.ts | 80 ------ .../Groups/GroupConfigurationProvider.ts | 104 ++++++++ .../Groups/GroupConfigurationTransformers.ts | 77 ------ .../Groups/RuntimeGroupConfiguration.d.ts | 23 -- source/Configuration/MenuHandler.ts | 177 ++++++++++++++ source/Database/Models/ConfigurationModel.ts | 6 + .../Models/GroupConfigurationModel.ts | 5 +- source/Database/Models/GroupModel.ts | 2 +- source/Database/Models/Model.ts | 2 +- source/Database/Models/PlaydateModel.ts | 2 +- source/Database/Repositories/Repository.ts | 2 +- source/Discord/Commands/Groups.ts | 78 +++++- source/Discord/Commands/Playdates.ts | 27 ++- source/Events/Handlers/ReminderEvent.ts | 19 +- .../Handlers/SendCreatedNotification.ts | 17 +- source/Menu/MenuRenderer.types.ts | 4 +- source/types/Class.ts | 4 +- 21 files changed, 551 insertions(+), 452 deletions(-) create mode 100644 source/Configuration/ConfigurationHandler.ts create mode 100644 source/Configuration/ConfigurationProvider.ts create mode 100644 source/Configuration/ConfigurationTransformer.ts delete mode 100644 source/Configuration/Groups/ConfigurationMenuHandler.ts delete mode 100644 source/Configuration/Groups/GroupConfigurationHandler.ts create mode 100644 source/Configuration/Groups/GroupConfigurationProvider.ts delete mode 100644 source/Configuration/Groups/GroupConfigurationTransformers.ts delete mode 100644 source/Configuration/Groups/RuntimeGroupConfiguration.d.ts create mode 100644 source/Configuration/MenuHandler.ts create mode 100644 source/Database/Models/ConfigurationModel.ts diff --git a/source/Configuration/ConfigurationHandler.ts b/source/Configuration/ConfigurationHandler.ts new file mode 100644 index 0000000..606f4bd --- /dev/null +++ b/source/Configuration/ConfigurationHandler.ts @@ -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, +> { + public readonly transformer: ConfigurationTransformer + + constructor( + private readonly provider: ConfigurationProvider + ) { + 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 { + const configuration = this.provider.get(path); + if (!configuration) { + return; + } + + return this.transformer.getValue(configuration); + } + + private getCompleteDatabaseConfig(): Partial { + const values = this.provider.getAll(); + const configuration: Partial = {}; + + values.forEach((configValue) => { + const value = this.transformer.getValue(configValue); + setPath(configuration, configValue.key, value); + }) + + return configuration; + } +} \ No newline at end of file diff --git a/source/Configuration/ConfigurationProvider.ts b/source/Configuration/ConfigurationProvider.ts new file mode 100644 index 0000000..a25b1d7 --- /dev/null +++ b/source/Configuration/ConfigurationProvider.ts @@ -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; + getAll(): TProviderModel[]; + + get defaults(): TRuntimeConfiguration; + + save(value: Omit & Partial): void; + + getTransformer(): ConfigurationTransformer; +} \ No newline at end of file diff --git a/source/Configuration/ConfigurationTransformer.ts b/source/Configuration/ConfigurationTransformer.ts new file mode 100644 index 0000000..de45447 --- /dev/null +++ b/source/Configuration/ConfigurationTransformer.ts @@ -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 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 { + const path = configKey.split('.'); + return this.transformers.find( + transformer => { + return ArrayUtils.arraysEqual(transformer.path, path); + } + )?.type; + } + + +} diff --git a/source/Configuration/Groups/ConfigurationMenuHandler.ts b/source/Configuration/Groups/ConfigurationMenuHandler.ts deleted file mode 100644 index 330a1e6..0000000 --- a/source/Configuration/Groups/ConfigurationMenuHandler.ts +++ /dev/null @@ -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(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((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); - } -} \ No newline at end of file diff --git a/source/Configuration/Groups/GroupConfigurationHandler.ts b/source/Configuration/Groups/GroupConfigurationHandler.ts deleted file mode 100644 index 87fe58d..0000000 --- a/source/Configuration/Groups/GroupConfigurationHandler.ts +++ /dev/null @@ -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 { - const configuration = this.repository.findConfigurationByPath(this.group, path); - if (!configuration) { - return null; - } - - return this.transformers.getValue(configuration); - } - - private getDatabaseConfiguration(): Partial { - const values = this.repository.findGroupConfigurations(this.group); - const configuration: Partial = {}; - - values.forEach((configValue) => { - const value = this.transformers.getValue(configValue); - setPath(configuration, configValue.key, value); - }) - - return configuration; - } -} \ No newline at end of file diff --git a/source/Configuration/Groups/GroupConfigurationProvider.ts b/source/Configuration/Groups/GroupConfigurationProvider.ts new file mode 100644 index 0000000..ed7cd83 --- /dev/null +++ b/source/Configuration/Groups/GroupConfigurationProvider.ts @@ -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, + 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 { + return this.repository.findConfigurationByPath(this.group, path); + } + getAll(): GroupConfigurationModel[] { + return this.repository.findGroupConfigurations(this.group); + } + save(value: Omit & Partial): 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 + } + ] + ) + } + +} \ No newline at end of file diff --git a/source/Configuration/Groups/GroupConfigurationTransformers.ts b/source/Configuration/Groups/GroupConfigurationTransformers.ts deleted file mode 100644 index 47a4380..0000000 --- a/source/Configuration/Groups/GroupConfigurationTransformers.ts +++ /dev/null @@ -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 configValue.value; - case TransformerType.PermissionBoolean: - return configValue.value === '1'; - case TransformerType.String: - return configValue.value; - } - } - - public getTransformerType(configKey: string): Nullable { - const path = configKey.split('.'); - return GroupConfigurationTransformers.TRANSFORMERS.find( - transformer => { - return ArrayUtils.arraysEqual(transformer.path, path); - } - )?.type; - } - - -} \ No newline at end of file diff --git a/source/Configuration/Groups/RuntimeGroupConfiguration.d.ts b/source/Configuration/Groups/RuntimeGroupConfiguration.d.ts deleted file mode 100644 index de076a3..0000000 --- a/source/Configuration/Groups/RuntimeGroupConfiguration.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {ChannelId} from "../../types/DiscordTypes"; -import {Nullable} from "../../types/Nullable"; - -export type RuntimeGroupConfiguration = { - channels: Nullable, - 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 -} \ No newline at end of file diff --git a/source/Configuration/MenuHandler.ts b/source/Configuration/MenuHandler.ts new file mode 100644 index 0000000..cdab74d --- /dev/null +++ b/source/Configuration/MenuHandler.ts @@ -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 | Partial); +type HandlerMenuItem = + FieldHandlerMenuItem | CollectionMenuItem + +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 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 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(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); + } +} \ No newline at end of file diff --git a/source/Database/Models/ConfigurationModel.ts b/source/Database/Models/ConfigurationModel.ts new file mode 100644 index 0000000..5291f00 --- /dev/null +++ b/source/Database/Models/ConfigurationModel.ts @@ -0,0 +1,6 @@ +import {Model} from "./Model"; + +export type ConfigurationModel = Model & { + key: string; + value: string; +} \ No newline at end of file diff --git a/source/Database/Models/GroupConfigurationModel.ts b/source/Database/Models/GroupConfigurationModel.ts index c4f624c..8e93449 100644 --- a/source/Database/Models/GroupConfigurationModel.ts +++ b/source/Database/Models/GroupConfigurationModel.ts @@ -1,8 +1,7 @@ import {Model} from "./Model"; import {GroupModel} from "./GroupModel"; +import {ConfigurationModel} from "./ConfigurationModel"; -export interface GroupConfigurationModel extends Model { +export type GroupConfigurationModel = ConfigurationModel & { group: GroupModel; - key: string; - value: string; } \ No newline at end of file diff --git a/source/Database/Models/GroupModel.ts b/source/Database/Models/GroupModel.ts index 950680d..9f02204 100644 --- a/source/Database/Models/GroupModel.ts +++ b/source/Database/Models/GroupModel.ts @@ -1,7 +1,7 @@ import {Model} from "./Model"; import {GuildMember, Role} from "../../types/DiscordTypes"; -export interface GroupModel extends Model { +export type GroupModel = Model & { name: string; leader: GuildMember; role: Role; diff --git a/source/Database/Models/Model.ts b/source/Database/Models/Model.ts index 7ecb067..528efed 100644 --- a/source/Database/Models/Model.ts +++ b/source/Database/Models/Model.ts @@ -1,3 +1,3 @@ -export interface Model { +export type Model = { id: number | bigint; } diff --git a/source/Database/Models/PlaydateModel.ts b/source/Database/Models/PlaydateModel.ts index d535b34..1446ab4 100644 --- a/source/Database/Models/PlaydateModel.ts +++ b/source/Database/Models/PlaydateModel.ts @@ -2,7 +2,7 @@ import {Model} from "./Model"; import {GroupModel} from "./GroupModel"; import {Nullable} from "../../types/Nullable"; -export interface PlaydateModel extends Model { +export type PlaydateModel = Model & { group: Nullable from_time: Date, to_time: Date, diff --git a/source/Database/Repositories/Repository.ts b/source/Database/Repositories/Repository.ts index dc65d87..3488f4e 100644 --- a/source/Database/Repositories/Repository.ts +++ b/source/Database/Repositories/Repository.ts @@ -41,7 +41,7 @@ export class Repository & { id: number }): boolean { + public update(instance: Partial & Model): boolean { const columnNames = this.schema.columns.filter((column) => { return !column.primaryKey }).map((column) => { diff --git a/source/Discord/Commands/Groups.ts b/source/Discord/Commands/Groups.ts index e83ab77..2e58d48 100644 --- a/source/Discord/Commands/Groups.ts +++ b/source/Discord/Commands/Groups.ts @@ -18,9 +18,6 @@ import {Container} from "../../Container/Container"; import {GroupSelection} from "../CommandPartials/GroupSelection"; import {UserError} from "../UserError"; import {ArrayUtils} from "../../Utilities/ArrayUtils"; -import {GroupConfigurationRenderer} from "../../Configuration/Groups/GroupConfigurationRenderer"; -import {GroupConfigurationHandler} from "../../Configuration/Groups/GroupConfigurationHandler"; -import {GroupConfigurationTransformers} from "../../Configuration/Groups/GroupConfigurationTransformers"; import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; import {PlaydateRepository} from "../../Database/Repositories/PlaydateRepository"; import {Nullable} from "../../types/Nullable"; @@ -28,6 +25,9 @@ import {MenuRenderer} from "../../Menu/MenuRenderer"; import {MenuItemType} from "../../Menu/MenuRenderer.types"; import {ConfigurationMenuHandler} from "../../Configuration/Groups/ConfigurationMenuHandler"; import {MenuTraversal} from "../../Menu/MenuTraversal"; +import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; +import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; +import {MenuHandler} from "../../Configuration/MenuHandler"; export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { private static GOODBYE_MESSAGES: string[] = [ @@ -238,17 +238,75 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple private async runConfigurator(interaction: ChatInputCommandInteraction) { const group = GroupSelection.getGroup(interaction); - const menuHandler = new ConfigurationMenuHandler( - new GroupConfigurationHandler( - Container.get(GroupConfigurationRepository.name), - group - ), - new GroupConfigurationTransformers(), + const menuHandler = new MenuHandler( + new ConfigurationHandler( + new GroupConfigurationProvider( + Container.get(GroupConfigurationRepository.name), + group + ) + ) ) const menu = new MenuRenderer( new MenuTraversal( - menuHandler.getMenuItems(), + menuHandler.fillMenuItems( + [ + { + 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.", + }, + { + traversalKey: "playdateReminders", + label: 'Playdate Reminders', + description: "Sets the channel, where the group gets reminded of upcoming playdates.", + } + ] + }, + { + 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.", + } + ] + }, + { + traversalKey: "calendar", + label: "Calendar", + description: "Provides settings for the metadata contained in the playdate exports.", + type: MenuItemType.Collection, + children: [ + { + traversalKey: "title", + label: "Title", + description: "Defines how the calendar entry should be called.", + }, + { + traversalKey: "description", + label: "Description", + description: "Sets the description for the calendar entry.", + }, + { + traversalKey: "location", + label: "Location", + description: "Sets the location where the calendar should point to." + } + ] + }, + ] + ), 'Group Configuration', "This UI allows you to change settings for your group." ) diff --git a/source/Discord/Commands/Playdates.ts b/source/Discord/Commands/Playdates.ts index 826582e..67d0899 100644 --- a/source/Discord/Commands/Playdates.ts +++ b/source/Discord/Commands/Playdates.ts @@ -7,9 +7,6 @@ import { ChatInputCommandInteraction, time, AttachmentBuilder, - ActivityFlagsBitField, - Options, - User, GuildMember } from "discord.js"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; @@ -21,10 +18,10 @@ import {PlaydateRepository} from "../../Database/Repositories/PlaydateRepository import {GroupModel} from "../../Database/Models/GroupModel"; import * as ics from 'ics'; import ical from 'node-ical'; -import {GroupConfigurationHandler} from "../../Configuration/Groups/GroupConfigurationHandler"; import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; -import {privateDecrypt} from "node:crypto"; import {GroupRepository} from "../../Database/Repositories/GroupRepository"; +import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; +import { ConfigurationHandler } from "../../Configuration/ConfigurationHandler"; export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { definition(): SlashCommandBuilder { @@ -314,10 +311,12 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter } private async export(interaction: ChatInputCommandInteraction, group: GroupModel): Promise { - const groupConfig = new GroupConfigurationHandler( - Container.get(GroupConfigurationRepository.name), - group - ).getConfiguration(); + const groupConfig = new ConfigurationHandler( + new GroupConfigurationProvider( + Container.get(GroupConfigurationRepository.name), + group + ) + ).getCompleteConfiguration(); const playdates = this.getExportTargets(interaction, group); @@ -396,10 +395,12 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter return false; } - const config = new GroupConfigurationHandler( - Container.get(GroupConfigurationRepository.name), - group + const config = new ConfigurationHandler( + new GroupConfigurationProvider( + Container.get(GroupConfigurationRepository.name), + group + ) ); - return config.getConfiguration().permissions.allowMemberManagingPlaydates; + return config.getConfigurationByPath("permissions.allowMemberManagingPlaydates") === true; } } \ No newline at end of file diff --git a/source/Events/Handlers/ReminderEvent.ts b/source/Events/Handlers/ReminderEvent.ts index 4f0680f..b1c5e45 100644 --- a/source/Events/Handlers/ReminderEvent.ts +++ b/source/Events/Handlers/ReminderEvent.ts @@ -1,6 +1,5 @@ import {Container} from "../../Container/Container"; import {PlaydateRepository} from "../../Database/Repositories/PlaydateRepository"; -import {GroupConfigurationHandler} from "../../Configuration/Groups/GroupConfigurationHandler"; import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; import {PlaydateModel} from "../../Database/Models/PlaydateModel"; import {ChannelId} from "../../types/DiscordTypes"; @@ -8,6 +7,12 @@ import {DiscordClient} from "../../Discord/DiscordClient"; import {EmbedBuilder, roleMention, time} from "discord.js"; import {ArrayUtils} from "../../Utilities/ArrayUtils"; import {EventConfiguration, EventType, TimedEvent} from "../EventHandler.types"; +import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; +import { + GroupConfigurationProvider, + RuntimeGroupConfiguration +} from "../../Configuration/Groups/GroupConfigurationProvider"; +import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel"; export class ReminderEvent implements TimedEvent { private static REMINDER_INTERVALS = [ @@ -60,13 +65,15 @@ export class ReminderEvent implements TimedEvent { if (!playdate.group) { return Promise.resolve(); } - - const configurationHandler = new GroupConfigurationHandler( - this.groupConfigurationRepository, - playdate.group + + const groupConfig = new ConfigurationHandler( + new GroupConfigurationProvider( + Container.get(GroupConfigurationRepository.name), + playdate.group + ) ); - const config = configurationHandler.getConfiguration(); + const config = groupConfig.getCompleteConfiguration(); const targetChannel = config.channels?.playdateReminders; if (!targetChannel) { diff --git a/source/Events/Handlers/SendCreatedNotification.ts b/source/Events/Handlers/SendCreatedNotification.ts index 63771b9..d628b99 100644 --- a/source/Events/Handlers/SendCreatedNotification.ts +++ b/source/Events/Handlers/SendCreatedNotification.ts @@ -3,10 +3,15 @@ import {PlaydateModel} from "../../Database/Models/PlaydateModel"; import PlaydateTableConfiguration from "../../Database/tables/Playdate"; import {EmbedBuilder, roleMention, time} from "discord.js"; import {ArrayUtils} from "../../Utilities/ArrayUtils"; -import {GroupConfigurationHandler} from "../../Configuration/Groups/GroupConfigurationHandler"; import {Container} from "../../Container/Container"; import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; import {DiscordClient} from "../../Discord/DiscordClient"; +import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; +import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel"; +import { + GroupConfigurationProvider, + RuntimeGroupConfiguration +} from "../../Configuration/Groups/GroupConfigurationProvider"; const NEW_PLAYDATE_MESSAGES = [ 'A new playdate was added. Lets hope, your GM has not planned to kill you. >:]', @@ -25,12 +30,14 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE } - const configurationHandler = new GroupConfigurationHandler( - Container.get(GroupConfigurationRepository.name), - playdate.group + const groupConfig = new ConfigurationHandler( + new GroupConfigurationProvider( + Container.get(GroupConfigurationRepository.name), + playdate.group + ) ); - const targetChannel = configurationHandler.getConfigurationByPath('channels.newPlaydates'); + const targetChannel = groupConfig.getConfigurationByPath('channels.newPlaydates'); if (!targetChannel) { return; } diff --git a/source/Menu/MenuRenderer.types.ts b/source/Menu/MenuRenderer.types.ts index ecd7e1f..b97572d 100644 --- a/source/Menu/MenuRenderer.types.ts +++ b/source/Menu/MenuRenderer.types.ts @@ -15,9 +15,9 @@ export type MenuItem = { description?: string } -export type CollectionMenuItem = MenuItem & { +export type CollectionMenuItem = MenuItem & { type: MenuItemType.Collection, - children: AnyMenuItem[] + children: TCollection[] } export type FieldMenuItem = MenuItem & { diff --git a/source/types/Class.ts b/source/types/Class.ts index d6d98c6..9200137 100644 --- a/source/types/Class.ts +++ b/source/types/Class.ts @@ -1,2 +1,4 @@ export type Class = { constructor: { name: string } } -export type ClassNamed = { name: string } \ No newline at end of file +export type ClassNamed = { name: string } + +export type ValueOf = T[keyof T] \ No newline at end of file