refactor(menu): Made sure the menu can be used for more than group
This commit is contained in:
parent
a79898b2e9
commit
1d73ee8a78
16 changed files with 650 additions and 406 deletions
26
package-lock.json
generated
26
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,8 +239,7 @@ 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
|
||||||
|
|
@ -241,7 +247,15 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +62,17 @@ export class InteractionRouter {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
10
source/Events/ComponentInteractionEvent.ts
Normal file
10
source/Events/ComponentInteractionEvent.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import {AnySelectMenuInteraction, ButtonInteraction} from "discord.js";
|
||||||
|
|
||||||
|
export class ComponentInteractionEvent {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly interaction: AnySelectMenuInteraction | ButtonInteraction
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
8
source/Events/ModalInteractionEvent.ts
Normal file
8
source/Events/ModalInteractionEvent.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {ModalSubmitInteraction} from "discord.js";
|
||||||
|
|
||||||
|
export class ModalInteractionEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly interaction: ModalSubmitInteraction
|
||||||
|
) {}
|
||||||
|
|
||||||
|
}
|
||||||
164
source/Groups/ConfigurationMenuHandler.ts
Normal file
164
source/Groups/ConfigurationMenuHandler.ts
Normal 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('; '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
source/Menu/InvalidTraversalRouteError.ts
Normal file
12
source/Menu/InvalidTraversalRouteError.ts
Normal 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('/')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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() ]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
43
source/Menu/MenuRenderer.types.ts
Normal file
43
source/Menu/MenuRenderer.types.ts
Normal 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;
|
||||||
105
source/Menu/MenuTraversal.ts
Normal file
105
source/Menu/MenuTraversal.ts
Normal 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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
7
source/Menu/MenuTraversal.types.ts
Normal file
7
source/Menu/MenuTraversal.types.ts
Normal 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>;
|
||||||
31
source/Menu/Modals/Modal.ts
Normal file
31
source/Menu/Modals/Modal.ts
Normal 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);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
17
source/Menu/Modals/Prompt.ts
Normal file
17
source/Menu/Modals/Prompt.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue