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; 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").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() 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((value).baseName) ?? "Unknown"; case TransformerType.Channel: return channelMention(value); default: return "None"; } } private createActionRowBuildersForMenu() : ActionRowBuilder[] { const {currentCollection, currentElement} = this.findCurrentUI(); if (currentElement?.isConfiguration ?? false) { return [ new ActionRowBuilder() .addComponents(this.getSelectForBreadcrumbs(currentElement)) ] } return [ new ActionRowBuilder() .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").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, currentCollection: UIElementCollection } { let currentCollection: UIElementCollection = GroupConfigurationRenderer.UI_ELEMENTS; let currentElement: Nullable = null; for (const breadcrumb of this.breadcrumbs) { currentElement = currentCollection[breadcrumb]; if (currentElement.isConfiguration ?? false) { break; } currentCollection = currentElement.childrenElements ?? {}; } return { currentElement, currentCollection, } } }