refactor(menu): Made sure the menu can be used for more than group

This commit is contained in:
Michel Fedde 2025-06-20 17:48:00 +02:00
parent a79898b2e9
commit 1d73ee8a78
16 changed files with 650 additions and 406 deletions

26
package-lock.json generated
View file

@ -3678,6 +3678,19 @@
"uuid": "^10.0.0" "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": { "node_modules/object-path-set": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/object-path-set/-/object-path-set-1.0.2.tgz", "resolved": "https://registry.npmjs.org/object-path-set/-/object-path-set-1.0.2.tgz",
@ -4539,19 +4552,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -32,11 +32,14 @@ export class Services {
const database = new DatabaseConnection(env.database); const database = new DatabaseConnection(env.database);
container.set<DatabaseConnection>(database); container.set<DatabaseConnection>(database);
const eventHandler = new EventHandler();
container.set<EventHandler>(eventHandler);
const restClient = new REST(); const restClient = new REST();
const commands = new Commands(); const commands = new Commands();
const discordClient = new DiscordClient( const discordClient = new DiscordClient(
env.discord.clientId, env.discord.clientId,
new InteractionRouter(commands, logger), new InteractionRouter(commands, logger, eventHandler),
new CommandDeployer(env.discord.clientId, restClient, commands, logger), new CommandDeployer(env.discord.clientId, restClient, commands, logger),
logger, logger,
restClient restClient
@ -46,7 +49,6 @@ export class Services {
const iconCache = new IconCache(discordClient); const iconCache = new IconCache(discordClient);
container.set<IconCache>(iconCache); container.set<IconCache>(iconCache);
container.set<EventHandler>(new EventHandler());
this.setupRepositories(container); this.setupRepositories(container);
} }

View file

@ -1,12 +1,15 @@
import { import {
SlashCommandBuilder,
ChatInputCommandInteraction,
MessageFlags,
InteractionReplyOptions,
GuildMember,
EmbedBuilder,
AutocompleteInteraction, AutocompleteInteraction,
roleMention, time, userMention, GuildMemberRoleManager ChatInputCommandInteraction,
EmbedBuilder,
GuildMember,
GuildMemberRoleManager,
InteractionReplyOptions,
MessageFlags,
roleMention,
SlashCommandBuilder,
time,
userMention
} from "discord.js"; } from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {GroupModel} from "../../Models/GroupModel"; import {GroupModel} from "../../Models/GroupModel";
@ -21,6 +24,10 @@ import {GroupConfigurationTransformers} from "../../Groups/GroupConfigurationTra
import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository"; import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository";
import {PlaydateRepository} from "../../Repositories/PlaydateRepository"; import {PlaydateRepository} from "../../Repositories/PlaydateRepository";
import {Nullable} from "../../types/Nullable"; 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 { export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand {
private static GOODBYE_MESSAGES: string[] = [ private static GOODBYE_MESSAGES: string[] = [
@ -232,16 +239,23 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
private async runConfigurator(interaction: ChatInputCommandInteraction) { private async runConfigurator(interaction: ChatInputCommandInteraction) {
const group = GroupSelection.getGroup(interaction); const group = GroupSelection.getGroup(interaction);
const menuHandler = new ConfigurationMenuHandler(
const configurationRenderer = new GroupConfigurationRenderer(
new GroupConfigurationHandler( new GroupConfigurationHandler(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name), Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
group group
), ),
new GroupConfigurationTransformers(), 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) { private async transferLeadership(interaction: ChatInputCommandInteraction) {

View file

@ -1,25 +1,33 @@
import { import {
AutocompleteInteraction, AnySelectMenuInteraction,
AutocompleteInteraction, ButtonInteraction,
ChatInputCommandInteraction, ChatInputCommandInteraction,
inlineCode, inlineCode,
Interaction, Interaction,
MessageFlags, MessageFlags, ModalSubmitInteraction,
} from "discord.js"; } from "discord.js";
import Commands from "./Commands/Commands"; import Commands from "./Commands/Commands";
import {Logger} from "log4js"; import {Logger} from "log4js";
import {UserError} from "./UserError"; import {UserError} from "./UserError";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
import {EventHandler} from "../Events/EventHandler";
import {ModalInteractionEvent} from "../Events/ModalInteractionEvent";
import {ComponentInteractionEvent} from "../Events/ComponentInteractionEvent";
enum InteractionRoutingType { enum InteractionRoutingType {
Unrouted, Unrouted,
Command, Command,
AutoComplete, AutoComplete,
ModalSubmit,
ButtonSubmit,
MenuSubmit,
} }
export class InteractionRouter { export class InteractionRouter {
constructor( constructor(
public readonly commands: Commands, 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: case InteractionRoutingType.AutoComplete:
await this.handleAutocomplete(<AutocompleteInteraction>interaction) await this.handleAutocomplete(<AutocompleteInteraction>interaction)
break; break;
case InteractionRoutingType.ModalSubmit:
this.events.dispatch(new ModalInteractionEvent(<ModalSubmitInteraction>interaction));
break;
case InteractionRoutingType.ButtonSubmit:
case InteractionRoutingType.MenuSubmit:
this.events.dispatch(new ComponentInteractionEvent(<ButtonInteraction|AnySelectMenuInteraction>interaction))
} }
} }
@ -47,6 +61,17 @@ export class InteractionRouter {
if (interaction.isAutocomplete()) { if (interaction.isAutocomplete()) {
return InteractionRoutingType.AutoComplete; return InteractionRoutingType.AutoComplete;
} }
if (interaction.isModalSubmit()) {
return InteractionRoutingType.ModalSubmit;
}
if (interaction.isButton()) {
return InteractionRoutingType.ButtonSubmit;
}
if (interaction.isAnySelectMenu()) {
return InteractionRoutingType.MenuSubmit;
}
return InteractionRoutingType.Unrouted; return InteractionRoutingType.Unrouted;
} }

View file

@ -0,0 +1,10 @@
import {AnySelectMenuInteraction, ButtonInteraction} from "discord.js";
export class ComponentInteractionEvent {
constructor(
public readonly interaction: AnySelectMenuInteraction | ButtonInteraction
) {
}
}

View file

@ -1,5 +1,6 @@
import cron from "node-cron"; import cron, {validate} from "node-cron";
import {Class} from "../types/Class"; import {Class} from "../types/Class";
import {randomUUID} from "node:crypto";
export type EventConfiguration = { export type EventConfiguration = {
name: string, name: string,
@ -13,17 +14,32 @@ export interface TimedEvent {
} }
export class EventHandler { export class EventHandler {
private eventHandlers: Map<string, CallableFunction[]> = new Map(); private eventHandlers: Map<string, Map<string, CallableFunction>> = new Map();
constructor() { constructor() {
} }
public addHandler<T extends Class>(eventName: string, handler: (event: T) => void) { public addHandler<T extends Class>(eventName: string, handler: (event: T) => void): string {
if (!this.eventHandlers.has(eventName)) { 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<T extends Class>(event: T) { public dispatch<T extends Class>(event: T) {

View file

@ -0,0 +1,8 @@
import {ModalSubmitInteraction} from "discord.js";
export class ModalInteractionEvent {
constructor(
public readonly interaction: ModalSubmitInteraction
) {}
}

View file

@ -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(<ChannelId>value);
}
private getChannelMenuBuilder(context: FieldMenuItemContext): MessageActionRowComponentBuilder {
return new ChannelSelectMenuBuilder()
.setChannelTypes(ChannelType.GuildText)
.setPlaceholder("New Value");
}
private getLocaleValue(context: FieldMenuItemContext): string {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
if (!value) {
return inlineCode("Default");
}
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
return displaynames.of((<Intl.Locale>value)?.baseName) ?? "Unknown";
}
private getLocaleMenuBuilder(context: FieldMenuItemContext): MessageActionRowComponentBuilder {
const options = [
'en-US',
'fr-FR',
'it-IT',
'de-DE'
]
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
return new StringSelectMenuBuilder()
.setOptions(
options.map(intl => new StringSelectMenuOptionBuilder()
.setLabel(displaynames.of(intl) ?? '')
.setValue(intl)
)
)
}
private getPermissionBooleanValue(context: FieldMenuItemContext) {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
return value ? 'Allowed' : "Disallowed";
}
private getPermissionBooleanBuilder(context: FieldMenuItemContext) {
return new StringSelectMenuBuilder()
.setOptions(
[
{
label: "Allow",
value: "1"
},
{
label: "Disallow",
value: "0"
}
]
)
}
private setValue(value: FieldMenuItemSaveValue[], context: FieldMenuItemContext): void {
this.configuration.saveConfiguration(context.path.join('.'), value.join('; '));
}
}

View file

@ -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<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
},
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(<AnySelectMenuInteraction>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>(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<ButtonBuilder>()
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((<Intl.Locale>value)?.baseName) ?? "Unknown";
case TransformerType.Channel:
if (!value) {
return inlineCode("None");
}
return channelMention(<ChannelId>value);
case TransformerType.PermissionBoolean:
return value ? "Allowed" : "Disallowed"
default:
return "None";
}
}
private createActionRowBuildersForMenu(): ActionRowBuilder<MessageActionRowComponentBuilder>[] {
const {currentCollection, currentElement} = this.findCurrentUI();
const icons = Container.get<IconCache>(IconCache.name);
if (currentElement?.isConfiguration ?? false) {
return [
new ActionRowBuilder<ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder>()
.addComponents(this.getSelectForBreadcrumbs())
]
}
return [
new ActionRowBuilder<ButtonBuilder>()
.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>("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<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,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('/')}`);
}
}

View file

@ -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<string>;
private exitButton: ButtonBuilder;
private backButton: ButtonBuilder;
constructor(
private readonly traversal: MenuTraversal,
private readonly eventHandler: EventHandler|null = null,
private readonly iconCache: Nullable<IconCache> = null
) {
this.eventHandler ??= Container.get<EventHandler>(EventHandler.name);
this.iconCache ??= Container.get<IconCache>(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>(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<MessageActionRowComponentBuilder>[] {
const navigation = new ActionRowBuilder<ButtonBuilder>()
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<MessageActionRowComponentBuilder> {
if (menuItem.type === MenuItemType.Collection) {
const navigation = new ActionRowBuilder<ButtonBuilder>();
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<MessageActionRowComponentBuilder>().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<string> = 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 = <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() ]
})
}
}

View file

@ -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;

View file

@ -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 <AnyMenuItem>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 <AnyMenuItem>this.traversalMap.get(path);
}
public static unstringifyTraversalPath(path: StringifiedTraversalPath): TraversalPath {
return path.split('/');
}
private generateTraversalMap(): TraversalMap {
const map = new Map<StringifiedTraversalPath, AnyMenuItem>();
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('/');
}
}

View file

@ -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<StringifiedTraversalPath, AnyMenuItem>;

View file

@ -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<ModalSubmitInteraction>
{
return new Promise<ModalSubmitInteraction>((resolve) => {
this.eventHandler.addHandler<ModalInteractionEvent>(ModalInteractionEvent.name, (ev) => {
if (this.modalId !== ev.interaction.customId) {
return;
}
resolve(ev.interaction);
})
})
}
}

View file

@ -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<string> {
const modal = this.getBuilder()
.setTitle(label);
await interaction.showModal(modal);
const responseInteraction = await this.awaitResponse();
responseInteraction.fields.getTextInputValue()
}
}