Adds group configuration

This commit is contained in:
Michel Fedde 2025-04-12 19:41:16 +02:00
parent 0d9cf6a370
commit 154002f6f3
16 changed files with 633 additions and 20 deletions

View file

@ -0,0 +1,68 @@
import {RuntimeGroupConfiguration} from "./RuntimeGroupConfiguration";
import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository";
import {GroupModel} from "../Models/GroupModel";
import {GroupConfigurationResult, GroupConfigurationTransformers} from "./GroupConfigurationTransformers";
// @ts-ignore
import setPath from 'object-path-set';
import deepmerge from "deepmerge";
import {Nullable} from "../types/Nullable";
export class GroupConfigurationHandler {
private static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = {
channels: null,
locale: new Intl.Locale('en-GB'),
}
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());
}
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,334 @@
import {GroupConfigurationTransformers, TransformerType} from "./GroupConfigurationTransformers";
import {GroupConfigurationHandler} from "./GroupConfigurationHandler";
import {
ActionRowBuilder,
AnyComponentBuilder, AnySelectMenuInteraction,
APISelectMenuComponent,
ButtonBuilder,
ButtonStyle, channelMention,
ChannelSelectMenuBuilder, ChannelSelectMenuInteraction,
ChannelType,
ChatInputCommandInteraction,
EmbedBuilder,
InteractionCallbackResponse,
InteractionEditReplyOptions,
InteractionReplyOptions,
InteractionUpdateOptions, italic,
SelectMenuBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
UserSelectMenuBuilder
} from "discord.js";
import {Logger} from "log4js";
import {Container} from "../Container/Container";
import {Nullable} from "../types/Nullable";
import GroupConfiguration from "../Database/tables/GroupConfiguration";
import {
BaseSelectMenuBuilder,
MentionableSelectMenuBuilder,
MessageActionRowComponentBuilder,
RoleSelectMenuBuilder
} from "@discordjs/builders";
import {unwatchFile} from "node:fs";
import {UserError} from "../Discord/UserError";
import {RuntimeGroupConfiguration} from "./RuntimeGroupConfiguration";
import {ChannelId} from "../types/DiscordTypes";
type UIElementCollection = Record<string, UIElement>;
type UIElement = {
label: string,
key: string,
description: string,
childrenElements?: UIElementCollection,
isConfiguration?: true
}
export class GroupConfigurationRenderer {
private static MOVETO_COMMAND = 'moveto_';
private static SETVALUE_COMMAND = 'setvalue_';
private static MOVEBACK_COMMAND = 'back';
private breadcrumbs: string[] = [];
private static UI_ELEMENTS : UIElementCollection = {
channels: {
label: 'Channels',
key: 'channels',
description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.",
childrenElements: {
newPlaydates: {
label: 'New Playdates',
key: 'newPlaydates',
description: "Sets the channel, where the group get notified when new Playdates are set.",
isConfiguration: true
},
playdateReminders: {
label: 'Playdate Reminders',
key: 'playdateReminders',
description: "Sets the channel, where the group gets reminded of upcoming playdates.",
isConfiguration: true
}
}
},
locale: {
label: "Locale",
key: 'locale',
description: "Provides locale to be used for this group. This mostly sets how the dates are displayed, but this can also be later used for translations.",
isConfiguration: true
}
}
constructor(
private readonly configurationHandler: GroupConfigurationHandler,
private readonly transformers: GroupConfigurationTransformers,
) {}
public async setup(interaction: ChatInputCommandInteraction) {
let response = await interaction.reply(this.getReplyOptions());
let exit = false;
let eventResponse;
const filter = i => i.user.id === interaction.user.id;
do {
if (eventResponse) {
response = await eventResponse.update(this.getReplyOptions());
}
try {
eventResponse = await response.resource?.message?.awaitMessageComponent({
dispose: true,
filter: filter,
time: 60_000
});
} catch (e) {
Container.get<Logger>("logger").error("awaiting message component failed: ", e)
}
if (!eventResponse || eventResponse.customId === 'exit') {
exit = true;
continue;
}
if (eventResponse.customId === GroupConfigurationRenderer.MOVEBACK_COMMAND) {
this.breadcrumbs.pop()
continue;
}
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.MOVETO_COMMAND)) {
this.breadcrumbs.push(
eventResponse.customId.substring(GroupConfigurationRenderer.MOVETO_COMMAND.length)
)
continue;
}
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) {
this.handleSelection(eventResponse);
continue;
}
} while(!exit);
if (eventResponse) {
try {
await eventResponse.update(
this.getReplyOptions()
);
} catch (e) {
}
await eventResponse.deleteReply();
return;
}
if (interaction.replied) {
await interaction.deleteReply();
}
}
private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } {
const embed = this.createEmbed();
embed.setAuthor({
name: "/ " + this.breadcrumbs.join(" / ")
});
const exitButton = new ButtonBuilder()
.setLabel("Exit")
.setStyle(ButtonStyle.Danger)
.setCustomId("exit");
const actionrow = new ActionRowBuilder<ButtonBuilder>()
if (this.breadcrumbs.length > 0) {
const backButton = new ButtonBuilder()
.setLabel("Back")
.setStyle(ButtonStyle.Secondary)
.setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND);
actionrow.addComponents(backButton)
}
actionrow.addComponents(exitButton)
return {
content: "",
embeds: [embed],
components: [...this.createActionRowBuildersForMenu(), actionrow],
withResponse: true,
};
}
private createEmbed(): EmbedBuilder {
const {currentCollection, currentElement} = this.findCurrentUI();
if (currentElement === null) {
return new EmbedBuilder()
.setTitle("Group Configuration")
.setDescription("This UI allows you to change settings for your group.")
}
const embed = new EmbedBuilder()
.setTitle(currentElement?.label ?? '')
.setDescription(currentElement?.description ?? '');
if (currentElement?.isConfiguration ?? false) {
embed.addFields(
{ name: "Current Value", value: this.getCurrentValueAsUI(), inline: false }
)
}
return embed;
}
private getCurrentValueAsUI(): string {
const path = this.breadcrumbs.join(".");
const value = this.configurationHandler.getConfigurationByPath(path);
if (value === undefined) return italic("None");
const type = this.transformers.getTransformerType(path);
if (type === undefined) {
throw new Error("Could not find the type for " + path);
}
const displaynames = new Intl.DisplayNames(["en"], { type: "language" });
switch (type) {
case TransformerType.Locale:
return displaynames.of((<Intl.Locale>value).baseName) ?? "Unknown";
case TransformerType.Channel:
return channelMention(<ChannelId>value);
default:
return "None";
}
}
private createActionRowBuildersForMenu() : ActionRowBuilder<MessageActionRowComponentBuilder>[] {
const {currentCollection, currentElement} = this.findCurrentUI();
if (currentElement?.isConfiguration ?? false) {
return [
new ActionRowBuilder<ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder>()
.addComponents(this.getSelectForBreadcrumbs(<UIElement>currentElement))
]
}
return [
new ActionRowBuilder<ButtonBuilder>()
.setComponents(
...Object.values(currentCollection).map(elem => new ButtonBuilder()
.setLabel(elem.label)
.setStyle(ButtonStyle.Primary)
.setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key)
)
)
]
}
private getSelectForBreadcrumbs(currentElement: UIElement): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder {
const breadcrumbPath = this.breadcrumbs.join('.')
const transformerType = this.transformers.getTransformerType(breadcrumbPath);
if (transformerType === undefined) {
throw new Error(`Can not find transformer type for ${breadcrumbPath}`)
}
switch (transformerType) {
case TransformerType.Locale:
const options = [
'en-US',
'fr-FR',
'it-IT',
'de-DE'
]
const displaynames = new Intl.DisplayNames(["en"], { type: "language" });
return new StringSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setOptions(
options.map(intl => new StringSelectMenuOptionBuilder()
.setLabel(displaynames.of(intl))
.setValue(intl)
)
)
case TransformerType.Channel:
return new ChannelSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setChannelTypes(ChannelType.GuildText)
.setPlaceholder("New Value");
default:
return new StringSelectMenuBuilder()
.setCustomId("...")
.setOptions(
new StringSelectMenuOptionBuilder()
.setLabel("Nothing to see here")
.setValue("0")
)
}
}
private handleSelection(interaction: AnySelectMenuInteraction) {
const path = interaction.customId.substring(GroupConfigurationRenderer.SETVALUE_COMMAND.length);
const savingValue = this.getSaveValue(interaction, path);
Container.get<Logger>("logger").debug(`Saving '${savingValue}' to '${path}'`);
this.configurationHandler.saveConfiguration(path, savingValue);
}
private getSaveValue(interaction: AnySelectMenuInteraction, path: string): string {
const transformerType = this.transformers.getTransformerType(path);
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can not find transformer type for ${path}`)
}
switch (transformerType) {
case TransformerType.Locale:
case TransformerType.Channel:
return interaction.values.join('; ');
default:
throw new Error("Unhandled select menu");
}
}
private findCurrentUI(): {currentElement: Nullable<UIElement>, currentCollection: UIElementCollection } {
let currentCollection: UIElementCollection = GroupConfigurationRenderer.UI_ELEMENTS;
let currentElement: Nullable<UIElement> = null;
for (const breadcrumb of this.breadcrumbs) {
currentElement = currentCollection[breadcrumb];
if (currentElement.isConfiguration ?? false) {
break;
}
currentCollection = currentElement.childrenElements ?? {};
}
return {
currentElement,
currentCollection,
}
}
}

View file

@ -0,0 +1,61 @@
import {ChannelId} from "../types/DiscordTypes";
import {GroupConfigurationModel} from "../Models/GroupConfigurationModel";
import {config} from "dotenv";
import {transform} from "esbuild";
import {Nullable} from "../types/Nullable";
import {ArrayUtils} from "../Utilities/ArrayUtils";
export enum TransformerType {
Locale,
Channel,
}
type GroupConfigurationTransformer = {
path: string[];
type: TransformerType,
}
export type GroupConfigurationResult =
ChannelId | Intl.Locale
export class GroupConfigurationTransformers {
static TRANSFORMERS: GroupConfigurationTransformer[] = [
{
path: ['channels', 'newPlaydates'],
type: TransformerType.Channel,
},
{
path: ['channels', 'playdateReminders'],
type: TransformerType.Channel,
},
{
path: ['locale'],
type: TransformerType.Locale,
}
];
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;
}
}
public getTransformerType(configKey: string): Nullable<TransformerType> {
const path = configKey.split('.');
return GroupConfigurationTransformers.TRANSFORMERS.find(
transformer => {
return ArrayUtils.arraysEqual(transformer.path, path);
}
)?.type;
}
}

View file

@ -0,0 +1,9 @@
export type RuntimeGroupConfiguration = {
channels: Nullable<ChannelRuntimeGroupConfiguration>,
locale: Intl.Locale,
};
export type ChannelRuntimeGroupConfiguration = {
newPlaydates: ChannelId,
playdateReminders: ChannelId
}