diff --git a/package-lock.json b/package-lock.json index f830d8c..47de5ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3678,6 +3678,19 @@ "uuid": "^10.0.0" } }, + "node_modules/node-ical/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/object-path-set": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/object-path-set/-/object-path-set-1.0.2.tgz", @@ -4539,19 +4552,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/source/Container/Services.ts b/source/Container/Services.ts index 82dfbf9..81ee210 100644 --- a/source/Container/Services.ts +++ b/source/Container/Services.ts @@ -32,11 +32,14 @@ export class Services { const database = new DatabaseConnection(env.database); container.set(database); + const eventHandler = new EventHandler(); + container.set(eventHandler); + const restClient = new REST(); const commands = new Commands(); const discordClient = new DiscordClient( env.discord.clientId, - new InteractionRouter(commands, logger), + new InteractionRouter(commands, logger, eventHandler), new CommandDeployer(env.discord.clientId, restClient, commands, logger), logger, restClient @@ -46,7 +49,6 @@ export class Services { const iconCache = new IconCache(discordClient); container.set(iconCache); - container.set(new EventHandler()); this.setupRepositories(container); } diff --git a/source/Discord/Commands/Groups.ts b/source/Discord/Commands/Groups.ts index ac3ea76..d817a43 100644 --- a/source/Discord/Commands/Groups.ts +++ b/source/Discord/Commands/Groups.ts @@ -1,12 +1,15 @@ import { - SlashCommandBuilder, - ChatInputCommandInteraction, - MessageFlags, - InteractionReplyOptions, - GuildMember, - EmbedBuilder, AutocompleteInteraction, - roleMention, time, userMention, GuildMemberRoleManager + ChatInputCommandInteraction, + EmbedBuilder, + GuildMember, + GuildMemberRoleManager, + InteractionReplyOptions, + MessageFlags, + roleMention, + SlashCommandBuilder, + time, + userMention } from "discord.js"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; import {GroupModel} from "../../Models/GroupModel"; @@ -21,6 +24,10 @@ import {GroupConfigurationTransformers} from "../../Groups/GroupConfigurationTra import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository"; import {PlaydateRepository} from "../../Repositories/PlaydateRepository"; import {Nullable} from "../../types/Nullable"; +import {MenuRenderer} from "../../Menu/MenuRenderer"; +import {MenuItemType} from "../../Menu/MenuRenderer.types"; +import {ConfigurationMenuHandler} from "../../Groups/ConfigurationMenuHandler"; +import {MenuTraversal} from "../../Menu/MenuTraversal"; export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { private static GOODBYE_MESSAGES: string[] = [ @@ -232,16 +239,23 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple private async runConfigurator(interaction: ChatInputCommandInteraction) { const group = GroupSelection.getGroup(interaction); - - const configurationRenderer = new GroupConfigurationRenderer( + const menuHandler = new ConfigurationMenuHandler( new GroupConfigurationHandler( Container.get(GroupConfigurationRepository.name), group ), new GroupConfigurationTransformers(), ) - - await configurationRenderer.setup(interaction); + + const menu = new MenuRenderer( + new MenuTraversal( + menuHandler.getMenuItems(), + 'Group Configuration', + "This UI allows you to change settings for your group." + ) + ) + + menu.display(interaction); } private async transferLeadership(interaction: ChatInputCommandInteraction) { diff --git a/source/Discord/InteractionRouter.ts b/source/Discord/InteractionRouter.ts index 407fb24..4e48d16 100644 --- a/source/Discord/InteractionRouter.ts +++ b/source/Discord/InteractionRouter.ts @@ -1,25 +1,33 @@ import { - AutocompleteInteraction, + AnySelectMenuInteraction, + AutocompleteInteraction, ButtonInteraction, ChatInputCommandInteraction, inlineCode, Interaction, - MessageFlags, + MessageFlags, ModalSubmitInteraction, } from "discord.js"; import Commands from "./Commands/Commands"; import {Logger} from "log4js"; import {UserError} from "./UserError"; import {Container} from "../Container/Container"; +import {EventHandler} from "../Events/EventHandler"; +import {ModalInteractionEvent} from "../Events/ModalInteractionEvent"; +import {ComponentInteractionEvent} from "../Events/ComponentInteractionEvent"; enum InteractionRoutingType { Unrouted, Command, AutoComplete, + ModalSubmit, + ButtonSubmit, + MenuSubmit, } export class InteractionRouter { constructor( public readonly commands: Commands, - public readonly logger: Logger + public readonly logger: Logger, + private readonly events: EventHandler ) { } @@ -36,6 +44,12 @@ export class InteractionRouter { case InteractionRoutingType.AutoComplete: await this.handleAutocomplete(interaction) break; + case InteractionRoutingType.ModalSubmit: + this.events.dispatch(new ModalInteractionEvent(interaction)); + break; + case InteractionRoutingType.ButtonSubmit: + case InteractionRoutingType.MenuSubmit: + this.events.dispatch(new ComponentInteractionEvent(interaction)) } } @@ -47,6 +61,17 @@ export class InteractionRouter { if (interaction.isAutocomplete()) { return InteractionRoutingType.AutoComplete; } + + if (interaction.isModalSubmit()) { + return InteractionRoutingType.ModalSubmit; + } + + if (interaction.isButton()) { + return InteractionRoutingType.ButtonSubmit; + } + if (interaction.isAnySelectMenu()) { + return InteractionRoutingType.MenuSubmit; + } return InteractionRoutingType.Unrouted; } diff --git a/source/Events/ComponentInteractionEvent.ts b/source/Events/ComponentInteractionEvent.ts new file mode 100644 index 0000000..c4a3f71 --- /dev/null +++ b/source/Events/ComponentInteractionEvent.ts @@ -0,0 +1,10 @@ +import {AnySelectMenuInteraction, ButtonInteraction} from "discord.js"; + +export class ComponentInteractionEvent { + + constructor( + public readonly interaction: AnySelectMenuInteraction | ButtonInteraction + ) { + } + +} \ No newline at end of file diff --git a/source/Events/EventHandler.ts b/source/Events/EventHandler.ts index 678587f..48f2ee7 100644 --- a/source/Events/EventHandler.ts +++ b/source/Events/EventHandler.ts @@ -1,5 +1,6 @@ -import cron from "node-cron"; +import cron, {validate} from "node-cron"; import {Class} from "../types/Class"; +import {randomUUID} from "node:crypto"; export type EventConfiguration = { name: string, @@ -13,17 +14,32 @@ export interface TimedEvent { } export class EventHandler { - private eventHandlers: Map = new Map(); + private eventHandlers: Map> = new Map(); constructor() { } - public addHandler(eventName: string, handler: (event: T) => void) { + public addHandler(eventName: string, handler: (event: T) => void): string { if (!this.eventHandlers.has(eventName)) { - this.eventHandlers.set(eventName, []); + this.eventHandlers.set(eventName, new Map()); } - this.eventHandlers.get(eventName)?.push(handler); + const id = randomUUID(); + this.eventHandlers.get(eventName)?.set(id, handler); + return id; + } + + public removeHandler(eventName: string, id: string) { + if (!this.eventHandlers.has(eventName)) { + return; + } + + const localEventHandlers = this.eventHandlers.get(eventName); + if (!localEventHandlers || !localEventHandlers.has(id)) { + return; + } + + localEventHandlers.delete(id); } public dispatch(event: T) { diff --git a/source/Events/ModalInteractionEvent.ts b/source/Events/ModalInteractionEvent.ts new file mode 100644 index 0000000..e92801d --- /dev/null +++ b/source/Events/ModalInteractionEvent.ts @@ -0,0 +1,8 @@ +import {ModalSubmitInteraction} from "discord.js"; + +export class ModalInteractionEvent { + constructor( + public readonly interaction: ModalSubmitInteraction + ) {} + +} \ No newline at end of file diff --git a/source/Groups/ConfigurationMenuHandler.ts b/source/Groups/ConfigurationMenuHandler.ts new file mode 100644 index 0000000..65f6415 --- /dev/null +++ b/source/Groups/ConfigurationMenuHandler.ts @@ -0,0 +1,164 @@ +import { + AnyMenuItem, FieldMenuItem, + FieldMenuItemContext, FieldMenuItemSaveValue, + MenuItemType, + RowBuilderFieldMenuItemContext +} from "../Menu/MenuRenderer.types"; +import {GroupConfigurationTransformers} from "./GroupConfigurationTransformers"; +import {GroupConfigurationHandler} from "./GroupConfigurationHandler"; +import { + channelMention, + ChannelSelectMenuBuilder, + ChannelType, + inlineCode, + italic, + Snowflake, + StringSelectMenuBuilder, StringSelectMenuOptionBuilder +} from "discord.js"; +import {ChannelId} from "../types/DiscordTypes"; +import {MessageActionRowComponentBuilder} from "@discordjs/builders"; + +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: "locale", + label: "Locale", + description: "Provides locale to be used for this group.", + type: MenuItemType.Field, + getCurrentValue: this.getLocaleValue.bind(this), + getActionRowBuilder: this.getLocaleMenuBuilder.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) + } + ] + } + ] + } + + 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 setValue(value: FieldMenuItemSaveValue[], context: FieldMenuItemContext): void { + this.configuration.saveConfiguration(context.path.join('.'), value.join('; ')); + } +} \ No newline at end of file diff --git a/source/Groups/GroupConfigurationRenderer.ts b/source/Groups/GroupConfigurationRenderer.ts deleted file mode 100644 index 1c4bd09..0000000 --- a/source/Groups/GroupConfigurationRenderer.ts +++ /dev/null @@ -1,372 +0,0 @@ -import {GroupConfigurationTransformers, TransformerType} from "./GroupConfigurationTransformers"; -import {GroupConfigurationHandler} from "./GroupConfigurationHandler"; -import { - ActionRowBuilder, - AnySelectMenuInteraction, - ButtonBuilder, - ButtonStyle, channelMention, - ChannelSelectMenuBuilder, ChannelType, - ChatInputCommandInteraction, EmbedBuilder, inlineCode, Interaction, - InteractionReplyOptions, - InteractionUpdateOptions, italic, MessageFlags, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, UserSelectMenuBuilder -} from "discord.js"; -import {Logger} from "log4js"; -import {Container} from "../Container/Container"; -import {Nullable} from "../types/Nullable"; -import { - MentionableSelectMenuBuilder, - MessageActionRowComponentBuilder, - RoleSelectMenuBuilder -} from "@discordjs/builders"; -import {ChannelId} from "../types/DiscordTypes"; -import {IconCache} from "../Icons/IconCache"; - -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 - }, - permissions: { - label: "Permissions", - key: "permissions", - description: "Allows customization, how the members are allowed to interact with the data stored in the group.", - childrenElements: { - allowMemberManagingPlaydates: { - label: "Manage Playdates", - key: "allowMemberManagingPlaydates", - description: "Defines if the members are allowed to manage playdates like adding or deleting them.", - 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: Interaction) => 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 (_: unknown) { - break; - } - - 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 (_) { - - } - await eventResponse.deleteReply(); - - return; - } - - const message = response.resource?.message - if (!message) { - return; - } - - if (message.deletable) { - await message.delete() - } - } - - private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } { - const embed = this.createEmbed(); - const icons = Container.get(IconCache.name); - embed.setAuthor({ - name: "/ " + this.breadcrumbs.join(" / ") - }); - - const exitButton = new ButtonBuilder() - .setLabel("Exit") - .setStyle(ButtonStyle.Danger) - .setCustomId("exit") - .setEmoji(icons.get("door_open_solid_white") ?? ''); - - const actionrow = new ActionRowBuilder() - - if (this.breadcrumbs.length > 0) { - const backButton = new ButtonBuilder() - .setLabel("Back") - .setStyle(ButtonStyle.Secondary) - .setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND) - .setEmoji(icons.get("angle_left_solid") ?? ''); - - actionrow.addComponents(backButton) - } - actionrow.addComponents(exitButton) - - return { - content: "", - embeds: [embed], - components: [...this.createActionRowBuildersForMenu(), actionrow], - withResponse: true, - flags: MessageFlags.Ephemeral - }; - } - - private createEmbed(): EmbedBuilder { - const {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: - if (!value) { - return inlineCode("Default"); - } - return displaynames.of((value)?.baseName) ?? "Unknown"; - case TransformerType.Channel: - if (!value) { - return inlineCode("None"); - } - return channelMention(value); - case TransformerType.PermissionBoolean: - return value ? "Allowed" : "Disallowed" - - default: - return "None"; - } - } - - private createActionRowBuildersForMenu(): ActionRowBuilder[] { - const {currentCollection, currentElement} = this.findCurrentUI(); - const icons = Container.get(IconCache.name); - - if (currentElement?.isConfiguration ?? false) { - return [ - new ActionRowBuilder() - .addComponents(this.getSelectForBreadcrumbs()) - ] - } - - return [ - new ActionRowBuilder() - .setComponents( - ...Object.values(currentCollection).map(elem => new ButtonBuilder() - .setLabel(` ${elem.label}`) - .setStyle(ButtonStyle.Primary) - .setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key) - .setEmoji(icons.get(elem.isConfiguration ? 'pen_solid' : "folder_solid") ?? '') - ) - ) - ] - } - - private getSelectForBreadcrumbs(): 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"); - case TransformerType.PermissionBoolean: - return new StringSelectMenuBuilder() - .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) - .setOptions( - [ - { - label: "Allow", - value: "1" - }, - { - label: "Disallow", - value: "0" - } - ] - ) - - 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: - case TransformerType.PermissionBoolean: - 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, - } - } -} \ No newline at end of file diff --git a/source/Menu/InvalidTraversalRouteError.ts b/source/Menu/InvalidTraversalRouteError.ts new file mode 100644 index 0000000..9e2a06d --- /dev/null +++ b/source/Menu/InvalidTraversalRouteError.ts @@ -0,0 +1,12 @@ +import {TraversalPath} from "./MenuTraversal.types"; + +export class InvalidTraversalRouteError extends Error { + name = "InvalidTraversalRouteError" + + constructor( + public readonly path: TraversalPath + ) { + super(`Menu found invalid traversal route: ${path.join('/')}`); + } + +} \ No newline at end of file diff --git a/source/Menu/MenuRenderer.ts b/source/Menu/MenuRenderer.ts index e69de29..a678542 100644 --- a/source/Menu/MenuRenderer.ts +++ b/source/Menu/MenuRenderer.ts @@ -0,0 +1,162 @@ +import {EventHandler} from "../Events/EventHandler"; +import {Container} from "../Container/Container"; +import {AnyMenuItem, MenuAction, MenuItemType, TraversalPath} from "./MenuRenderer.types"; +import {randomUUID} from "node:crypto"; +import {ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, EmbedBuilder, MessageFlags} from "discord.js"; +import {Nullable} from "../types/Nullable"; +import {IconCache} from "../Icons/IconCache"; +import {MessageActionRowComponentBuilder} from "@discordjs/builders"; +import {ComponentInteractionEvent} from "../Events/ComponentInteractionEvent"; +import {MenuTraversal} from "./MenuTraversal"; + +export class MenuRenderer { + private readonly menuId: string; + private eventId: Nullable; + + private exitButton: ButtonBuilder; + private backButton: ButtonBuilder; + + constructor( + private readonly traversal: MenuTraversal, + private readonly eventHandler: EventHandler|null = null, + private readonly iconCache: Nullable = null + ) { + this.eventHandler ??= Container.get(EventHandler.name); + this.iconCache ??= Container.get(IconCache.name); + this.menuId = randomUUID(); + + this.exitButton = new ButtonBuilder() + .setLabel("Exit") + .setStyle(ButtonStyle.Danger) + .setCustomId(this.getInteractionId("EXIT")) + .setEmoji(this.iconCache?.get("door_open_solid_white") ?? ''); + + this.backButton = new ButtonBuilder() + .setLabel("Back") + .setStyle(ButtonStyle.Secondary) + .setCustomId(this.getInteractionId("MOVE", "BACK")) + .setEmoji(this.iconCache?.get("angle_left_solid") ?? ''); + } + + public async display(interaction: CommandInteraction) { + this.eventId = this.eventHandler?.addHandler(ComponentInteractionEvent.name, this.handleUIEvents.bind(this)); + + await interaction.reply({ + content: "", + components: this.getActionRows(), + embeds: [this.getEmbed()], + withResponse: true, + flags: MessageFlags.Ephemeral + }) + } + + public close() { + this.eventHandler?.removeHandler(ComponentInteractionEvent.name, this.eventId ?? ''); + } + + private getActionRows(): ActionRowBuilder[] { + const navigation = new ActionRowBuilder() + + if (!this.traversal.isRoot) { + navigation.addComponents(this.backButton); + } + + const rows = [ + this.getComponentForMenuItem(this.traversal.currentMenuItem), + navigation + ]; + + return rows.filter(row => row.components.length > 0); + } + + private getComponentForMenuItem(menuItem: AnyMenuItem): ActionRowBuilder { + if (menuItem.type === MenuItemType.Collection) { + const navigation = new ActionRowBuilder(); + navigation.setComponents( + ...menuItem.children.map(item => new ButtonBuilder() + .setLabel(item.label) + .setStyle(ButtonStyle.Primary) + .setCustomId(this.getInteractionId("MOVE", item.traversalKey)) + .setEmoji(this.iconCache?.get(item.type === MenuItemType.Field ? 'pen_solid' : "folder_solid") ?? '') + ) + ) + + return navigation + } + + if (menuItem.type === MenuItemType.Field) { + const action = menuItem.getActionRowBuilder({ + path: this.traversal.path + }) + action.setCustomId(this.getInteractionId("SET", this.traversal.stringifiedPath)); + return new ActionRowBuilder().setComponents([action]); + } + + return new ActionRowBuilder(); + } + + private getEmbed(): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(this.traversal.currentMenuItem.label) + .setDescription(this.traversal.currentMenuItem.description ?? '') + .setAuthor({ + name: "/ " + this.traversal.path.join(' / ') + }); + + if (this.traversal.currentMenuItem.type === MenuItemType.Field) { + const currentValue = this.traversal.currentMenuItem.getCurrentValue({ + path: this.traversal.path + }); + embed.addFields({ + name: "Current Value", + value: currentValue + }); + } + + return embed; + } + + private getInteractionId(action: MenuAction, parameter: Nullable = undefined): string { + return `${this.menuId};${action};${parameter ?? ''}` + } + + private handleUIEvents(ev: ComponentInteractionEvent) { + if (!ev.interaction.customId.startsWith(this.menuId)) { + return; + } + + const [, action, parameter ] = ev.interaction.customId.split(';') + const menuAction = action; + + switch (menuAction) { + case "MOVE": + if (parameter === 'BACK') { + this.traversal.travelBack(); + break; + } + + this.traversal.travelForward(parameter); + break; + case "SET": + { + const value = ev.interaction.isAnySelectMenu() ? ev.interaction.values : [""]; + const menuItem = this.traversal.getMenuItem(parameter); + + if (menuItem.type !== MenuItemType.Field) { + break; + } + + menuItem.setValue(value, { + path: MenuTraversal.unstringifyTraversalPath(parameter) + }) + + break; + } + } + + ev.interaction.update({ + components: this.getActionRows(), + embeds: [ this.getEmbed() ] + }) + } +} \ No newline at end of file diff --git a/source/Menu/MenuRenderer.types.ts b/source/Menu/MenuRenderer.types.ts new file mode 100644 index 0000000..ecd7e1f --- /dev/null +++ b/source/Menu/MenuRenderer.types.ts @@ -0,0 +1,43 @@ +import {MessageActionRowComponentBuilder} from "@discordjs/builders"; +import {TraversalKey, TraversalPath} from "./MenuTraversal.types"; +import {MenuTraversal} from "./MenuTraversal"; +import {Snowflake, TextInputBuilder} from "discord.js"; + +export enum MenuItemType { + Collection, + Field, + Prompt +} + +export type MenuItem = { + traversalKey: TraversalKey, + label: string, + description?: string +} + +export type CollectionMenuItem = MenuItem & { + type: MenuItemType.Collection, + children: AnyMenuItem[] +} + +export type FieldMenuItem = MenuItem & { + type: MenuItemType.Field, + getCurrentValue(context: FieldMenuItemContext): string, + getActionRowBuilder(context: FieldMenuItemContext): MessageActionRowComponentBuilder, + setValue(value: FieldMenuItemSaveValue[], context: FieldMenuItemContext): void +} +export type PromptMenuItem = MenuItem & { + type: MenuItemType.Prompt, + getCurrentValue(context: FieldMenuItemContext): string, + getActionRowBuilder(context: FieldMenuItemContext): TextInputBuilder, + setValue(value: string, context: FieldMenuItemContext): void +} + +export type AnyMenuItem = CollectionMenuItem | FieldMenuItem | PromptMenuItem; +export type MenuAction = "MOVE"|"SET"|"EXIT"; + +export type FieldMenuItemContext = { + path: TraversalPath, +} + +export type FieldMenuItemSaveValue = string | Snowflake; \ No newline at end of file diff --git a/source/Menu/MenuTraversal.ts b/source/Menu/MenuTraversal.ts new file mode 100644 index 0000000..e22a082 --- /dev/null +++ b/source/Menu/MenuTraversal.ts @@ -0,0 +1,105 @@ +import {AnyMenuItem, MenuItemType} from "./MenuRenderer.types"; +import {TraversalMap, StringifiedTraversalPath, TraversalPath, TraversalKey} from "./MenuTraversal.types"; +import {InvalidTraversalRouteError} from "./InvalidTraversalRouteError"; + +export class MenuTraversal { + private readonly traversalMap: TraversalMap + private currentPath: TraversalPath = []; + + public get path() { + return this.currentPath; + } + + public get stringifiedPath(): StringifiedTraversalPath { + return this.stringifyTraversalPath(this.currentPath); + } + + public get currentMenuItem(): AnyMenuItem { + const path = this.stringifiedPath; + if (!this.traversalMap.has(path)) { + throw new InvalidTraversalRouteError(this.currentPath); + } + + return this.traversalMap.get(path); + } + + public get isRoot(): boolean { + return this.currentPath.length === 0; + } + + constructor( + private readonly menu: AnyMenuItem[], + private readonly rootLabel: string = "", + private readonly rootDescription: string = '' + ) { + this.traversalMap = this.generateTraversalMap(); + + } + + public travelForward(next: TraversalKey) { + const nextPath = [ + ...this.currentPath, + next + ]; + + if (!this.traversalMap.has(this.stringifyTraversalPath(nextPath))) { + throw new InvalidTraversalRouteError(nextPath); + } + + this.currentPath = nextPath; + } + + public travelBack() { + this.currentPath.pop(); + } + + public getMenuItem(path: StringifiedTraversalPath): AnyMenuItem + { + if (!this.traversalMap.has(path)) { + throw new InvalidTraversalRouteError([path]); + } + + return this.traversalMap.get(path); + } + + public static unstringifyTraversalPath(path: StringifiedTraversalPath): TraversalPath { + return path.split('/'); + } + + private generateTraversalMap(): TraversalMap { + const map = new Map(); + + map.set('', { + traversalKey: '', + label: this.rootLabel, + description: this.rootDescription, + type: MenuItemType.Collection, + children: this.menu + }); + + const that = this; + + function traversePath(path: TraversalPath, menuItem: AnyMenuItem) { + path = [...path, menuItem.traversalKey]; + map.set(that.stringifyTraversalPath(path), menuItem); + + if (menuItem.type !== MenuItemType.Collection) { + return; + } + + menuItem.children.forEach((nextMenuItem) => { + traversePath(path, nextMenuItem); + }) + } + + this.menu.forEach((menuItem) => { + traversePath([], menuItem); + }) + + return map; + } + + private stringifyTraversalPath(path: TraversalPath): StringifiedTraversalPath { + return path.join('/'); + } +} \ No newline at end of file diff --git a/source/Menu/MenuTraversal.types.ts b/source/Menu/MenuTraversal.types.ts new file mode 100644 index 0000000..0216c17 --- /dev/null +++ b/source/Menu/MenuTraversal.types.ts @@ -0,0 +1,7 @@ +import {AnyMenuItem} from "./MenuRenderer.types"; + +export type TraversalKey = string; +export type TraversalPath = TraversalKey[]; +export type StringifiedTraversalPath = string; + +export type TraversalMap = Map; \ No newline at end of file diff --git a/source/Menu/Modals/Modal.ts b/source/Menu/Modals/Modal.ts new file mode 100644 index 0000000..50f89a4 --- /dev/null +++ b/source/Menu/Modals/Modal.ts @@ -0,0 +1,31 @@ +import {EventHandler} from "../../Events/EventHandler"; +import {randomUUID} from "node:crypto"; +import {ModalBuilder, ModalSubmitInteraction} from "discord.js"; +import {ModalInteractionEvent} from "../../Events/ModalInteractionEvent"; + +export abstract class Modal { + private readonly modalId: string; + protected constructor( + private readonly eventHandler: EventHandler + ) { + this.modalId = randomUUID(); + } + + protected getBuilder(): ModalBuilder { + return new ModalBuilder() + .setCustomId(this.modalId); + } + + protected awaitResponse(): Promise + { + return new Promise((resolve) => { + this.eventHandler.addHandler(ModalInteractionEvent.name, (ev) => { + if (this.modalId !== ev.interaction.customId) { + return; + } + resolve(ev.interaction); + }) + }) + } + +} \ No newline at end of file diff --git a/source/Menu/Modals/Prompt.ts b/source/Menu/Modals/Prompt.ts new file mode 100644 index 0000000..26ee246 --- /dev/null +++ b/source/Menu/Modals/Prompt.ts @@ -0,0 +1,17 @@ +import {Modal} from "./Modal"; +import {Interaction, MessageComponentInteraction, TextInputBuilder} from "discord.js"; + +export class Prompt extends Modal { + public async requestValue( + label: string, + field: TextInputBuilder, + interaction: MessageComponentInteraction + ): Promise { + const modal = this.getBuilder() + .setTitle(label); + + await interaction.showModal(modal); + const responseInteraction = await this.awaitResponse(); + responseInteraction.fields.getTextInputValue() + } +} \ No newline at end of file