From 6a56f7f3e6c712a5141e25c8e41bdf66c6f2f963 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Fri, 23 Jul 2021 17:23:25 -0400 Subject: [PATCH 01/50] Add UUID npm package --- package-lock.json | 16 +++++++++++++++- package.json | 3 ++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 667f34e..fcd5de3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0-dev", "license": "MIT", "dependencies": { - "dotenv": "^10.0.0" + "dotenv": "^10.0.0", + "uuid": "^8.3.2" }, "devDependencies": { "@babel/core": "^7.14.6", @@ -9494,6 +9495,14 @@ "node": ">=4" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", @@ -16831,6 +16840,11 @@ "prepend-http": "^2.0.0" } }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", diff --git a/package.json b/package.json index 073c3b1..603de3b 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "typescript": "^4.3.5" }, "dependencies": { - "dotenv": "^10.0.0" + "dotenv": "^10.0.0", + "uuid": "^8.3.2" }, "files": [ "/dist" From 3349e53f5d341e1d6dff2ea4c61f6e4339e26c4d Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 24 Jul 2021 18:10:07 -0400 Subject: [PATCH 02/50] Add UI module and registerUI() method This feature allows users to build complex discord UIs more easily by abstracting `customId` management into dispatch. --- package-lock.json | 13 ++++++++++ package.json | 1 + src/ButtonHandler.ts | 6 ++++- src/Client.ts | 55 ++++++++++++++++++++++++++++++++++++------- src/Command.ts | 3 ++- src/UI.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++ src/dispatch.ts | 8 ++++--- src/index.ts | 1 + 8 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 src/UI.ts diff --git a/package-lock.json b/package-lock.json index fcd5de3..ae03fc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/dotenv": "^8.2.0", "@types/jest": "^26.0.24", "@types/node": "^16.3.1", + "@types/uuid": "^8.3.1", "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", "babel-jest": "^27.0.6", @@ -2620,6 +2621,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/uuid": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==", + "dev": true + }, "node_modules/@types/ws": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.6.tgz", @@ -11694,6 +11701,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/uuid": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==", + "dev": true + }, "@types/ws": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.6.tgz", diff --git a/package.json b/package.json index 603de3b..7a115fb 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/dotenv": "^8.2.0", "@types/jest": "^26.0.24", "@types/node": "^16.3.1", + "@types/uuid": "^8.3.1", "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", "babel-jest": "^27.0.6", diff --git a/src/ButtonHandler.ts b/src/ButtonHandler.ts index 337079b..63a0ec9 100644 --- a/src/ButtonHandler.ts +++ b/src/ButtonHandler.ts @@ -1,3 +1,7 @@ import { ButtonInteraction } from 'discord.js'; +import Client from './Client'; -export type ButtonHandler = (interaction: ButtonInteraction) => void | Promise; +export type ButtonHandler = ( + interaction: ButtonInteraction, + client: Client +) => void | Promise; diff --git a/src/Client.ts b/src/Client.ts index f686c5f..c596377 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -2,17 +2,27 @@ import discord, { ApplicationCommand, ApplicationCommandData, ApplicationCommandPermissionData, + ButtonInteraction, ClientOptions, CommandInteraction, Guild, GuildApplicationCommandPermissionData, + MessageActionRow, Snowflake, } from 'discord.js'; import { dispatch } from './dispatch'; import { loadCommands } from './loadCommands'; import { Command } from './Command'; +import { ButtonHandler } from './ButtonHandler'; +import { toComponents, UIComponent } from './UI'; export default class Client extends discord.Client { + /** + * A map from button IDs to handler functions. This is used to implement + * button click handlers. + */ + buttonListeners: Map = new Map(); + /** * Handles commands for the bot. */ @@ -32,9 +42,7 @@ export default class Client extends discord.Client { } if (!guild) { - throw new Error( - 'Guild is not initialized, check your GUILD_ID.' - ); + throw new Error('Guild is not initialized, check your GUILD_ID.'); } let pushedCommands: ApplicationCommand[] | undefined; @@ -95,24 +103,55 @@ export default class Client extends discord.Client { // Enable dispatcher. this.on('interactionCreate', (interaction) => { if (interaction instanceof CommandInteraction) { - dispatch(interaction, commands); + dispatch(interaction, commands, this); } // FIXME figure out a button/select menu api that - /* if (interaction instanceof ButtonInteraction) { - const handler = client.buttonListeners.get(interaction.customId); + const handler = this.buttonListeners.get(interaction.customId); if (!handler) { return; } // Run handler. - handler(interaction); + handler(interaction, this); } - */ }); } + + /** + * Generates a discord.js `MessageActionRow[]` that can be used in a + * message reply as the `components` argument. Allows use of `onClick` and + * `onSelect` by autogenerating and registering IDs. + * + * @param ui Either a single `UIComponent` or a 1D or 2D array of `UIComponent`s + * @returns a generated `MessageActionRow[]` + */ + registerUI( + ui: UIComponent | UIComponent[] | UIComponent[][] + ): MessageActionRow[] { + /* + * We allow the user to pass in a single UI element, a row of elements, or + * multiple rows of elements. + */ + if (!Array.isArray(ui)) { + // single item, so we need to wrap in [][] because toComponents expects a UIComponent[][] + return toComponents(this, [[ui]]); + } else { + const maybeArray: UIComponent | UIComponent[] | undefined = ui[0]; + if (maybeArray === undefined) { + // we had an empty single array + return toComponents(this, [[]]); + } else if (Array.isArray(maybeArray)) { + // we cast because it must be a 2d array + return toComponents(this, ui as UIComponent[][]); + } else { + // only a 1d array, so wrap in an array once + return toComponents(this, [ui as UIComponent[]]); + } + } + } } /** diff --git a/src/Command.ts b/src/Command.ts index df92477..bf74241 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -3,6 +3,7 @@ import { CommandInteraction, Snowflake, } from 'discord.js'; +import Client from './Client'; export type PermissionHandler = (interaction: CommandInteraction) => boolean | string | Promise; @@ -29,7 +30,7 @@ export interface Command { * The function that gets executed after the command * is invoked. */ - run(interaction: CommandInteraction): Promise | void; + run(interaction: CommandInteraction, client: Client): Promise | void; /** * The static role permissions for this command. diff --git a/src/UI.ts b/src/UI.ts new file mode 100644 index 0000000..9a0ed98 --- /dev/null +++ b/src/UI.ts @@ -0,0 +1,56 @@ +import { + MessageActionRow, + MessageButtonOptions, +} from 'discord.js'; +import { ButtonHandler } from './ButtonHandler'; +import Client from './Client'; +import { v4 as uuidv4 } from 'uuid'; + +export type UIComponent = DispatchButton; + +export type ButtonOptions = MessageButtonOptions & { + onClick?: ButtonHandler; +}; + +export class DispatchButton { + constructor(readonly options: ButtonOptions) {} +} + +export function toComponents( + client: Client, + components: UIComponent[][] +): MessageActionRow[] { + const configInRows: ButtonOptions[][] = components.map( + (row) => + row.map((component) => { + if (component instanceof DispatchButton) { + if (component.options.onClick) { + const id = getID( + component.options.label ?? '', + 'button' + ); + const copy: ButtonOptions = { + ...component.options, + customId: id, + type: 'BUTTON', + }; + delete copy.onClick; + client.buttonListeners.set(id, component.options.onClick); + return copy; + } else { + return component.options; + } + } else { + throw new Error('No such component type!'); + } + }) + ); + return configInRows.map((row) => + new MessageActionRow().addComponents(...row) + ); +} + +function getID(label: string, componentType: string): string { + const uuid: string = uuidv4(); + return `${label}$${componentType}$${uuid}`; +} diff --git a/src/dispatch.ts b/src/dispatch.ts index 66a044b..1739560 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -1,9 +1,11 @@ import { CommandInteraction } from 'discord.js'; import { Command } from './Command'; +import Client from './Client'; export async function dispatch( interaction: CommandInteraction, - commands: Command[] + commands: Command[], + client: Client ): Promise { // FIXME O(n) performance const command = commands.find((c) => c.name === interaction.commandName); @@ -41,11 +43,11 @@ export async function dispatch( if (process.env.NODE_ENV === 'production') { try { console.log('got here'); - await command.run(interaction); + await command.run(interaction, client); } catch(error) { console.error(error); } } else { - await command.run(interaction); + await command.run(interaction, client); } } diff --git a/src/index.ts b/src/index.ts index 93bb5c9..a5b475a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,5 +2,6 @@ export * from './ButtonHandler'; export { default as Client } from './Client'; export * from './Command'; export * from './dispatch'; +export * from './UI'; export * from './utils/permissions'; export * from './utils/role'; From 58959eaa25a2f25aabc930f9ae7f5707830eedf0 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 24 Jul 2021 19:36:35 -0400 Subject: [PATCH 03/50] Fix bug with link buttons --- src/UI.ts | 56 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/UI.ts b/src/UI.ts index 9a0ed98..4003897 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -1,45 +1,61 @@ import { + EmojiIdentifierResolvable, MessageActionRow, MessageButtonOptions, + MessageButtonStyleResolvable, } from 'discord.js'; import { ButtonHandler } from './ButtonHandler'; import Client from './Client'; import { v4 as uuidv4 } from 'uuid'; -export type UIComponent = DispatchButton; +export type UIComponent = DispatchButton | DispatchLinkButton; -export type ButtonOptions = MessageButtonOptions & { - onClick?: ButtonHandler; +export type ButtonOptions = { + disabled?: boolean; + emoji?: EmojiIdentifierResolvable; + label?: string; + style: Exclude; + onClick: ButtonHandler; +}; + +export type LinkButtonOptions = { + disabled?: boolean; + emoji?: EmojiIdentifierResolvable; + label?: string; + url: string; }; export class DispatchButton { constructor(readonly options: ButtonOptions) {} } +export class DispatchLinkButton { + constructor(readonly options: LinkButtonOptions) {} +} + export function toComponents( client: Client, components: UIComponent[][] ): MessageActionRow[] { - const configInRows: ButtonOptions[][] = components.map( + const configInRows: MessageButtonOptions[][] = components.map( (row) => row.map((component) => { if (component instanceof DispatchButton) { - if (component.options.onClick) { - const id = getID( - component.options.label ?? '', - 'button' - ); - const copy: ButtonOptions = { - ...component.options, - customId: id, - type: 'BUTTON', - }; - delete copy.onClick; - client.buttonListeners.set(id, component.options.onClick); - return copy; - } else { - return component.options; - } + const { onClick, ...options } = component.options; + // nonlink buttons must have a customId + const id = getID( + component.options.label ?? '', + 'button' + ); + client.buttonListeners.set(id, onClick); + return { ...options, type: 'BUTTON', customId: id }; + } else if (component instanceof DispatchLinkButton) { + // we override style in case the user omitted it + return { + ...component.options, + type: 'BUTTON', + style: 'LINK' + }; } else { throw new Error('No such component type!'); } From fa5c6fcad81007ee71968d5e95db4d6465e243f8 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Tue, 27 Jul 2021 21:02:01 -0400 Subject: [PATCH 04/50] Use an object for run() parameters --- src/Command.ts | 12 ++++++++++-- src/dispatch.ts | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Command.ts b/src/Command.ts index bf74241..31e9388 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -5,7 +5,9 @@ import { } from 'discord.js'; import Client from './Client'; -export type PermissionHandler = (interaction: CommandInteraction) => boolean | string | Promise; +export type PermissionHandler = ( + interaction: CommandInteraction +) => boolean | string | Promise; /** * Represents a the blueprint for a slash commands. @@ -30,7 +32,13 @@ export interface Command { * The function that gets executed after the command * is invoked. */ - run(interaction: CommandInteraction, client: Client): Promise | void; + run({ + interaction, + client, + }: { + interaction: CommandInteraction; + client: Client; + }): Promise | void; /** * The static role permissions for this command. diff --git a/src/dispatch.ts b/src/dispatch.ts index fdf6506..5997fda 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -41,7 +41,7 @@ export async function dispatch( } try { - await command.run(interaction, client); + await command.run({ interaction, client }); } catch(error) { console.error(error); } From 6c7a8c57b5e403e20ccc828a8b84ae04be61441f Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Tue, 27 Jul 2021 21:31:48 -0400 Subject: [PATCH 05/50] Inject registerUI function directly --- src/Command.ts | 9 ++++++--- src/dispatch.ts | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Command.ts b/src/Command.ts index 31e9388..2604aa6 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -1,9 +1,10 @@ import { ApplicationCommandOptionData, CommandInteraction, + MessageActionRow, Snowflake, } from 'discord.js'; -import Client from './Client'; +import { UIComponent } from './UI'; export type PermissionHandler = ( interaction: CommandInteraction @@ -34,10 +35,12 @@ export interface Command { */ run({ interaction, - client, + registerUI, }: { interaction: CommandInteraction; - client: Client; + registerUI: ( + ui: UIComponent | UIComponent[] | UIComponent[][] + ) => MessageActionRow[]; }): Promise | void; /** diff --git a/src/dispatch.ts b/src/dispatch.ts index 5997fda..dd3449e 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -41,8 +41,8 @@ export async function dispatch( } try { - await command.run({ interaction, client }); - } catch(error) { + await command.run({ interaction, registerUI: client.registerUI }); + } catch (error) { console.error(error); } } From 447bfc9292743bf1e46e9b767da81a2a719d9186 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Tue, 27 Jul 2021 23:53:23 -0400 Subject: [PATCH 06/50] Update docs --- src/Command.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Command.ts b/src/Command.ts index 2604aa6..59ed0f8 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -30,8 +30,11 @@ export interface Command { readonly options?: ApplicationCommandOptionData[]; /** - * The function that gets executed after the command - * is invoked. + * The function that gets executed after the command is invoked. + * @param args + * @param args.interaction Interaction object from discord.js + * @param args.registerUI **Must be called once per message!** Generates a + * discord.js compatible UI from Dispatch components. */ run({ interaction, From 17348798c4ce041a0f13e40d309a791546489820 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Tue, 27 Jul 2021 23:57:44 -0400 Subject: [PATCH 07/50] Remove FIXME --- src/Client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Client.ts b/src/Client.ts index c596377..ed324b9 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -106,7 +106,6 @@ export default class Client extends discord.Client { dispatch(interaction, commands, this); } - // FIXME figure out a button/select menu api that if (interaction instanceof ButtonInteraction) { const handler = this.buttonListeners.get(interaction.customId); From 12c0397319026c8a34ba991c195d35c401b9e6b2 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 00:08:24 -0400 Subject: [PATCH 08/50] Fix bug with this binding --- src/Client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index ed324b9..f9694f1 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -127,9 +127,9 @@ export default class Client extends discord.Client { * @param ui Either a single `UIComponent` or a 1D or 2D array of `UIComponent`s * @returns a generated `MessageActionRow[]` */ - registerUI( + registerUI = ( ui: UIComponent | UIComponent[] | UIComponent[][] - ): MessageActionRow[] { + ): MessageActionRow[] => { /* * We allow the user to pass in a single UI element, a row of elements, or * multiple rows of elements. @@ -150,7 +150,7 @@ export default class Client extends discord.Client { return toComponents(this, [ui as UIComponent[]]); } } - } + }; } /** From 924e7f7846b812d9c0d55db87b132b424a88dc3e Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 00:23:05 -0400 Subject: [PATCH 09/50] Remove needless dependency on Client --- src/Client.ts | 8 ++++---- src/UI.ts | 7 +++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index f9694f1..7c52cc5 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -136,18 +136,18 @@ export default class Client extends discord.Client { */ if (!Array.isArray(ui)) { // single item, so we need to wrap in [][] because toComponents expects a UIComponent[][] - return toComponents(this, [[ui]]); + return toComponents([[ui]], this.buttonListeners); } else { const maybeArray: UIComponent | UIComponent[] | undefined = ui[0]; if (maybeArray === undefined) { // we had an empty single array - return toComponents(this, [[]]); + return toComponents([[]], this.buttonListeners); } else if (Array.isArray(maybeArray)) { // we cast because it must be a 2d array - return toComponents(this, ui as UIComponent[][]); + return toComponents(ui as UIComponent[][], this.buttonListeners); } else { // only a 1d array, so wrap in an array once - return toComponents(this, [ui as UIComponent[]]); + return toComponents([ui as UIComponent[]], this.buttonListeners); } } }; diff --git a/src/UI.ts b/src/UI.ts index 4003897..dd26bcb 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -5,7 +5,6 @@ import { MessageButtonStyleResolvable, } from 'discord.js'; import { ButtonHandler } from './ButtonHandler'; -import Client from './Client'; import { v4 as uuidv4 } from 'uuid'; export type UIComponent = DispatchButton | DispatchLinkButton; @@ -34,8 +33,8 @@ export class DispatchLinkButton { } export function toComponents( - client: Client, - components: UIComponent[][] + components: UIComponent[][], + buttonListeners: Map, ): MessageActionRow[] { const configInRows: MessageButtonOptions[][] = components.map( (row) => @@ -47,7 +46,7 @@ export function toComponents( component.options.label ?? '', 'button' ); - client.buttonListeners.set(id, onClick); + buttonListeners.set(id, onClick); return { ...options, type: 'BUTTON', customId: id }; } else if (component instanceof DispatchLinkButton) { // we override style in case the user omitted it From 263b6343898e37253eeecfd308c6105dcb2dcfd1 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 00:23:46 -0400 Subject: [PATCH 10/50] Reformat code --- src/UI.ts | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/UI.ts b/src/UI.ts index dd26bcb..b821a11 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -34,31 +34,27 @@ export class DispatchLinkButton { export function toComponents( components: UIComponent[][], - buttonListeners: Map, + buttonListeners: Map ): MessageActionRow[] { - const configInRows: MessageButtonOptions[][] = components.map( - (row) => - row.map((component) => { - if (component instanceof DispatchButton) { - const { onClick, ...options } = component.options; - // nonlink buttons must have a customId - const id = getID( - component.options.label ?? '', - 'button' - ); - buttonListeners.set(id, onClick); - return { ...options, type: 'BUTTON', customId: id }; - } else if (component instanceof DispatchLinkButton) { - // we override style in case the user omitted it - return { - ...component.options, - type: 'BUTTON', - style: 'LINK' - }; - } else { - throw new Error('No such component type!'); - } - }) + const configInRows: MessageButtonOptions[][] = components.map((row) => + row.map((component) => { + if (component instanceof DispatchButton) { + const { onClick, ...options } = component.options; + // nonlink buttons must have a customId + const id = getID(component.options.label ?? '', 'button'); + buttonListeners.set(id, onClick); + return { ...options, type: 'BUTTON', customId: id }; + } else if (component instanceof DispatchLinkButton) { + // we override style in case the user omitted it + return { + ...component.options, + type: 'BUTTON', + style: 'LINK', + }; + } else { + throw new Error('No such component type!'); + } + }) ); return configInRows.map((row) => new MessageActionRow().addComponents(...row) From 904910d30fe71068153544f2a186001c27f7dd10 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 00:28:43 -0400 Subject: [PATCH 11/50] Extract normalization code to function --- src/Client.ts | 21 +-------------------- src/UI.ts | 30 ++++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 7c52cc5..3c28dfd 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -130,26 +130,7 @@ export default class Client extends discord.Client { registerUI = ( ui: UIComponent | UIComponent[] | UIComponent[][] ): MessageActionRow[] => { - /* - * We allow the user to pass in a single UI element, a row of elements, or - * multiple rows of elements. - */ - if (!Array.isArray(ui)) { - // single item, so we need to wrap in [][] because toComponents expects a UIComponent[][] - return toComponents([[ui]], this.buttonListeners); - } else { - const maybeArray: UIComponent | UIComponent[] | undefined = ui[0]; - if (maybeArray === undefined) { - // we had an empty single array - return toComponents([[]], this.buttonListeners); - } else if (Array.isArray(maybeArray)) { - // we cast because it must be a 2d array - return toComponents(ui as UIComponent[][], this.buttonListeners); - } else { - // only a 1d array, so wrap in an array once - return toComponents([ui as UIComponent[]], this.buttonListeners); - } - } + return toComponents(ui, this.buttonListeners); }; } diff --git a/src/UI.ts b/src/UI.ts index b821a11..2f82302 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -33,10 +33,11 @@ export class DispatchLinkButton { } export function toComponents( - components: UIComponent[][], + components: UIComponent | UIComponent[] | UIComponent[][], buttonListeners: Map ): MessageActionRow[] { - const configInRows: MessageButtonOptions[][] = components.map((row) => + const normalizedUI = normalizeUI(components); + const configInRows: MessageButtonOptions[][] = normalizedUI.map((row) => row.map((component) => { if (component instanceof DispatchButton) { const { onClick, ...options } = component.options; @@ -65,3 +66,28 @@ function getID(label: string, componentType: string): string { const uuid: string = uuidv4(); return `${label}$${componentType}$${uuid}`; } + +function normalizeUI( + ui: UIComponent | UIComponent[] | UIComponent[][] +): UIComponent[][] { + /* + * We allow the user to pass in a single UI element, a row of elements, or + * multiple rows of elements. + */ + if (!Array.isArray(ui)) { + // single item, so we need to wrap in [][] because toComponents expects a UIComponent[][] + return [[ui]]; + } else { + const maybeArray: UIComponent | UIComponent[] | undefined = ui[0]; + if (maybeArray === undefined) { + // we had an empty single array + return [[]]; + } else if (Array.isArray(maybeArray)) { + // we cast because it must be a 2d array + return ui as UIComponent[][]; + } else { + // only a 1d array, so wrap in an array once + return [ui as UIComponent[]]; + } + } +} From afcfd0a9c4af19204a5aabf67684b37d0123fa2a Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 17:22:01 -0400 Subject: [PATCH 12/50] Convert to idiomatic OOP style --- src/UI.ts | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/UI.ts b/src/UI.ts index 2f82302..5dbd0f5 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -26,10 +26,29 @@ export type LinkButtonOptions = { export class DispatchButton { constructor(readonly options: ButtonOptions) {} + + toDiscordComponent( + buttonListeners: Map + ): MessageButtonOptions { + const { onClick, ...options } = this.options; + // nonlink buttons must have a customId + const id = getID(this.options.label ?? '', 'button'); + buttonListeners.set(id, onClick); + return { ...options, type: 'BUTTON', customId: id }; + } } export class DispatchLinkButton { constructor(readonly options: LinkButtonOptions) {} + + toDiscordComponent(): MessageButtonOptions { + // we override style in case the user omitted it + return { + ...this.options, + type: 'BUTTON', + style: 'LINK', + }; + } } export function toComponents( @@ -38,24 +57,7 @@ export function toComponents( ): MessageActionRow[] { const normalizedUI = normalizeUI(components); const configInRows: MessageButtonOptions[][] = normalizedUI.map((row) => - row.map((component) => { - if (component instanceof DispatchButton) { - const { onClick, ...options } = component.options; - // nonlink buttons must have a customId - const id = getID(component.options.label ?? '', 'button'); - buttonListeners.set(id, onClick); - return { ...options, type: 'BUTTON', customId: id }; - } else if (component instanceof DispatchLinkButton) { - // we override style in case the user omitted it - return { - ...component.options, - type: 'BUTTON', - style: 'LINK', - }; - } else { - throw new Error('No such component type!'); - } - }) + row.map((component) => component.toDiscordComponent(buttonListeners)) ); return configInRows.map((row) => new MessageActionRow().addComponents(...row) From b31b393f482829319c1ac7170842b6e901b5920d Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 17:29:28 -0400 Subject: [PATCH 13/50] Simplify ButtonHandler arguments --- src/ButtonHandler.ts | 6 +----- src/Client.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ButtonHandler.ts b/src/ButtonHandler.ts index 63a0ec9..337079b 100644 --- a/src/ButtonHandler.ts +++ b/src/ButtonHandler.ts @@ -1,7 +1,3 @@ import { ButtonInteraction } from 'discord.js'; -import Client from './Client'; -export type ButtonHandler = ( - interaction: ButtonInteraction, - client: Client -) => void | Promise; +export type ButtonHandler = (interaction: ButtonInteraction) => void | Promise; diff --git a/src/Client.ts b/src/Client.ts index 3c28dfd..9b2335f 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -114,7 +114,7 @@ export default class Client extends discord.Client { } // Run handler. - handler(interaction, this); + handler(interaction); } }); } From cbcc9f6ad540fb7b0b210c2fd456279b921966a9 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 17:31:33 -0400 Subject: [PATCH 14/50] Improve docs --- src/Command.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Command.ts b/src/Command.ts index 59ed0f8..fc963a9 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -33,8 +33,8 @@ export interface Command { * The function that gets executed after the command is invoked. * @param args * @param args.interaction Interaction object from discord.js - * @param args.registerUI **Must be called once per message!** Generates a - * discord.js compatible UI from Dispatch components. + * @param args.registerUI **Must be called at most once per message!** + * Generates a discord.js compatible UI from Dispatch components. */ run({ interaction, From 74283e6ce45693dddeb26e87da8973517aa74214 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 17:50:24 -0400 Subject: [PATCH 15/50] Add validation for row size constraints --- src/UI.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/UI.ts b/src/UI.ts index 5dbd0f5..45dc91c 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -59,6 +59,13 @@ export function toComponents( const configInRows: MessageButtonOptions[][] = normalizedUI.map((row) => row.map((component) => component.toDiscordComponent(buttonListeners)) ); + // validate row constraints + configInRows.forEach((row) => { + if (row.length > 5) { + throw new Error('Rows cannot have more than 5 elements!\n' + + `Row containing "${row.map(x => x.label).join(' ')}" is invalid.`); + } + }); return configInRows.map((row) => new MessageActionRow().addComponents(...row) ); From 4577a98ad63b628d9aee655cb61a558a41167ddc Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 18:21:57 -0400 Subject: [PATCH 16/50] Improve typing of ButtonOptions --- src/UI.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/UI.ts b/src/UI.ts index 45dc91c..6254ab2 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -9,10 +9,7 @@ import { v4 as uuidv4 } from 'uuid'; export type UIComponent = DispatchButton | DispatchLinkButton; -export type ButtonOptions = { - disabled?: boolean; - emoji?: EmojiIdentifierResolvable; - label?: string; +export type ButtonOptions = Omit & { style: Exclude; onClick: ButtonHandler; }; From cda6aae9d842e2fc8aa2e00e95a3b21c58d88d35 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 18:24:05 -0400 Subject: [PATCH 17/50] Use Omit with LinkButtonOptions as well --- src/UI.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/UI.ts b/src/UI.ts index 6254ab2..d17936e 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -14,10 +14,7 @@ export type ButtonOptions = Omit & { url: string; }; From 3c681df142a27bc06bec241af422f7f91ada0edb Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 18:24:23 -0400 Subject: [PATCH 18/50] Format code --- src/UI.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/UI.ts b/src/UI.ts index d17936e..f657b8b 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -9,12 +9,18 @@ import { v4 as uuidv4 } from 'uuid'; export type UIComponent = DispatchButton | DispatchLinkButton; -export type ButtonOptions = Omit & { +export type ButtonOptions = Omit< + MessageButtonOptions, + 'customId' | 'style' | 'url' +> & { style: Exclude; onClick: ButtonHandler; }; -export type LinkButtonOptions = Omit & { +export type LinkButtonOptions = Omit< + MessageButtonOptions, + 'customId' | 'style' | 'url' +> & { url: string; }; @@ -56,8 +62,10 @@ export function toComponents( // validate row constraints configInRows.forEach((row) => { if (row.length > 5) { - throw new Error('Rows cannot have more than 5 elements!\n' + - `Row containing "${row.map(x => x.label).join(' ')}" is invalid.`); + throw new Error( + 'Rows cannot have more than 5 elements!\n' + + `Row containing "${row.map((x) => x.label).join(' ')}" is invalid.` + ); } }); return configInRows.map((row) => From 605cedd42a78fb4476a1939e2aa38b0ebc202107 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Wed, 28 Jul 2021 18:27:04 -0400 Subject: [PATCH 19/50] Fix lint error --- src/UI.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/UI.ts b/src/UI.ts index f657b8b..039bc6a 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -1,5 +1,4 @@ import { - EmojiIdentifierResolvable, MessageActionRow, MessageButtonOptions, MessageButtonStyleResolvable, From 57021709753dad5740d0978c9227d00a20dc983b Mon Sep 17 00:00:00 2001 From: suneettipirneni Date: Sat, 31 Jul 2021 10:43:54 -0400 Subject: [PATCH 20/50] bump discord.js --- package-lock.json | 53 +++++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index ae03fc5..610c630 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", "babel-jest": "^27.0.6", - "discord.js": "^13.0.0-dev.4886ae2.1627128182", + "discord.js": "^13.0.0-dev.t1627732975.331a9d3", "eslint": "^7.30.0", "jest": "^27.0.6", "nodemon": "^2.0.12", @@ -1658,10 +1658,13 @@ } }, "node_modules/@discordjs/collection": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.1.6.tgz", - "integrity": "sha512-utRNxnd9kSS2qhyivo9lMlt5qgAUasH2gb7BEOn6p0efFh24gjGomHzWKMAPn2hEReOPQZCJaRKoURwRotKucQ==", - "dev": true + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.2.1.tgz", + "integrity": "sha512-vhxqzzM8gkomw0TYRF3tgx7SwElzUlXT/Aa41O7mOcyN6wIJfj5JmDWaO5XGKsGSsNx7F3i5oIlrucCCWV1Nog==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } }, "node_modules/@discordjs/form-data": { "version": "3.0.1", @@ -3960,32 +3963,32 @@ } }, "node_modules/discord-api-types": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.19.0.tgz", - "integrity": "sha512-t2HKLd43Lbe+rf+ffYfKVv9Kk5f6p7sFqvO6CMV55ZB0PgZv8WigCkt9FoJciYo5S3Q6CGYK+WnE/ZG+6vkBDQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.21.0.tgz", + "integrity": "sha512-x/YpcXK2tS7kQRavirl5/QnYD1U8L3HzCTMkg+gwDEp+IkE8XGCLJHiZZoEKIAvpJFv5XzT5IPsduTIB2ONbuA==", "dev": true, "engines": { "node": ">=12" } }, "node_modules/discord.js": { - "version": "13.0.0-dev.4886ae2.1627128182", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.4886ae2.1627128182.tgz", - "integrity": "sha512-e13Bpj+zNsFLJbezTZRztBerKn1vmwbal0nIDP0inRZnEnZbHPWQ5/F+2JDsGHL0TTLsuTNr28zkKtdoU/2WTw==", + "version": "13.0.0-dev.t1627732975.331a9d3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.t1627732975.331a9d3.tgz", + "integrity": "sha512-AOA/jJNzdtkJjjCBuiwVx5Q3cEVBClYB8XPBJ0wdrm6VUu+YNqJJyPFHM1DqyYwtPN8Jw7YPzPEqDVidCOn+Pg==", "dev": true, "dependencies": { "@discordjs/builders": "^0.2.0", - "@discordjs/collection": "^0.1.6", + "@discordjs/collection": "^0.2.1", "@discordjs/form-data": "^3.0.1", "@sapphire/async-queue": "^1.1.4", "@types/ws": "^7.4.5", "abort-controller": "^3.0.0", - "discord-api-types": "^0.19.0", + "discord-api-types": "^0.21.0", "node-fetch": "^2.6.1", "ws": "^7.5.1" }, "engines": { - "node": ">=14.0.0", + "node": ">=14.6.0", "npm": ">=7.0.0" } }, @@ -10937,9 +10940,9 @@ } }, "@discordjs/collection": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.1.6.tgz", - "integrity": "sha512-utRNxnd9kSS2qhyivo9lMlt5qgAUasH2gb7BEOn6p0efFh24gjGomHzWKMAPn2hEReOPQZCJaRKoURwRotKucQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.2.1.tgz", + "integrity": "sha512-vhxqzzM8gkomw0TYRF3tgx7SwElzUlXT/Aa41O7mOcyN6wIJfj5JmDWaO5XGKsGSsNx7F3i5oIlrucCCWV1Nog==", "dev": true }, "@discordjs/form-data": { @@ -12710,24 +12713,24 @@ } }, "discord-api-types": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.19.0.tgz", - "integrity": "sha512-t2HKLd43Lbe+rf+ffYfKVv9Kk5f6p7sFqvO6CMV55ZB0PgZv8WigCkt9FoJciYo5S3Q6CGYK+WnE/ZG+6vkBDQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.21.0.tgz", + "integrity": "sha512-x/YpcXK2tS7kQRavirl5/QnYD1U8L3HzCTMkg+gwDEp+IkE8XGCLJHiZZoEKIAvpJFv5XzT5IPsduTIB2ONbuA==", "dev": true }, "discord.js": { - "version": "13.0.0-dev.4886ae2.1627128182", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.4886ae2.1627128182.tgz", - "integrity": "sha512-e13Bpj+zNsFLJbezTZRztBerKn1vmwbal0nIDP0inRZnEnZbHPWQ5/F+2JDsGHL0TTLsuTNr28zkKtdoU/2WTw==", + "version": "13.0.0-dev.t1627732975.331a9d3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.t1627732975.331a9d3.tgz", + "integrity": "sha512-AOA/jJNzdtkJjjCBuiwVx5Q3cEVBClYB8XPBJ0wdrm6VUu+YNqJJyPFHM1DqyYwtPN8Jw7YPzPEqDVidCOn+Pg==", "dev": true, "requires": { "@discordjs/builders": "^0.2.0", - "@discordjs/collection": "^0.1.6", + "@discordjs/collection": "^0.2.1", "@discordjs/form-data": "^3.0.1", "@sapphire/async-queue": "^1.1.4", "@types/ws": "^7.4.5", "abort-controller": "^3.0.0", - "discord-api-types": "^0.19.0", + "discord-api-types": "^0.21.0", "node-fetch": "^2.6.1", "ws": "^7.5.1" } diff --git a/package.json b/package.json index 7a115fb..30d38f4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", "babel-jest": "^27.0.6", - "discord.js": "^13.0.0-dev.4886ae2.1627128182", + "discord.js": "^13.0.0-dev.t1627732975.331a9d3", "eslint": "^7.30.0", "jest": "^27.0.6", "nodemon": "^2.0.12", From 235240284b28cbc3d658fa94bd00bd7102816229 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 00:10:32 -0400 Subject: [PATCH 21/50] Inject guild into commands --- src/Command.ts | 3 +++ src/dispatch.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Command.ts b/src/Command.ts index fc963a9..01a69ca 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -1,6 +1,7 @@ import { ApplicationCommandOptionData, CommandInteraction, + Guild, MessageActionRow, Snowflake, } from 'discord.js'; @@ -39,11 +40,13 @@ export interface Command { run({ interaction, registerUI, + guild, }: { interaction: CommandInteraction; registerUI: ( ui: UIComponent | UIComponent[] | UIComponent[][] ) => MessageActionRow[]; + guild: Guild; }): Promise | void; /** diff --git a/src/dispatch.ts b/src/dispatch.ts index dd3449e..80e2252 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -40,8 +40,20 @@ export async function dispatch( } } + if (interaction.guild === null) { + await interaction.reply({ + content: 'Unexpected null guild property!', + ephemeral: true, + }); + return; + } + try { - await command.run({ interaction, registerUI: client.registerUI }); + await command.run({ + interaction, + registerUI: client.registerUI, + guild: interaction.guild, + }); } catch (error) { console.error(error); } From 97c61beed69c0988d7b47430a33ff0d31a7b5820 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 00:44:04 -0400 Subject: [PATCH 22/50] Add support for select menus --- src/Client.ts | 20 ++++++++++++++-- src/UI.ts | 63 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 9b2335f..20fc079 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -8,13 +8,14 @@ import discord, { Guild, GuildApplicationCommandPermissionData, MessageActionRow, + SelectMenuInteraction, Snowflake, } from 'discord.js'; import { dispatch } from './dispatch'; import { loadCommands } from './loadCommands'; import { Command } from './Command'; import { ButtonHandler } from './ButtonHandler'; -import { toComponents, UIComponent } from './UI'; +import { SelectMenuHandler, toComponents, UIComponent } from './UI'; export default class Client extends discord.Client { /** @@ -22,6 +23,11 @@ export default class Client extends discord.Client { * button click handlers. */ buttonListeners: Map = new Map(); + /** + * A map from select menu IDs to handler functions. This is used to implement + * select click handlers. + */ + selectMenuListeners: Map = new Map(); /** * Handles commands for the bot. @@ -116,6 +122,16 @@ export default class Client extends discord.Client { // Run handler. handler(interaction); } + + if (interaction instanceof SelectMenuInteraction) { + const handler = this.selectMenuListeners.get(interaction.customId); + + if (!handler) { + return; + } + + handler(interaction); + } }); } @@ -130,7 +146,7 @@ export default class Client extends discord.Client { registerUI = ( ui: UIComponent | UIComponent[] | UIComponent[][] ): MessageActionRow[] => { - return toComponents(ui, this.buttonListeners); + return toComponents(ui, this.buttonListeners, this.selectMenuListeners); }; } diff --git a/src/UI.ts b/src/UI.ts index 039bc6a..6673ba5 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -2,11 +2,16 @@ import { MessageActionRow, MessageButtonOptions, MessageButtonStyleResolvable, + MessageSelectMenuOptions, + SelectMenuInteraction, } from 'discord.js'; import { ButtonHandler } from './ButtonHandler'; import { v4 as uuidv4 } from 'uuid'; -export type UIComponent = DispatchButton | DispatchLinkButton; +export type UIComponent = + | DispatchButton + | DispatchLinkButton + | DispatchSelectMenu; export type ButtonOptions = Omit< MessageButtonOptions, @@ -26,9 +31,11 @@ export type LinkButtonOptions = Omit< export class DispatchButton { constructor(readonly options: ButtonOptions) {} - toDiscordComponent( - buttonListeners: Map - ): MessageButtonOptions { + toDiscordComponent({ + buttonListeners, + }: { + buttonListeners: Map; + }): MessageButtonOptions { const { onClick, ...options } = this.options; // nonlink buttons must have a customId const id = getID(this.options.label ?? '', 'button'); @@ -50,20 +57,58 @@ export class DispatchLinkButton { } } +export type SelectMenuOptions = Omit & { + onSelect: SelectMenuHandler; +}; +export type SelectMenuHandler = ( + interaction: SelectMenuInteraction +) => void | Promise; + +export class DispatchSelectMenu { + constructor(readonly options: SelectMenuOptions) {} + + toDiscordComponent({ + selectMenuListeners, + }: { + selectMenuListeners: Map; + }): MessageSelectMenuOptions { + const { onSelect, ...options } = this.options; + const id = getID(this.options.placeholder ?? '', 'select'); + selectMenuListeners.set(id, onSelect); + return { + ...options, + type: 'SELECT_MENU', + customId: id, + }; + } +} + export function toComponents( components: UIComponent | UIComponent[] | UIComponent[][], - buttonListeners: Map + buttonListeners: Map, + selectMenuListeners: Map ): MessageActionRow[] { const normalizedUI = normalizeUI(components); - const configInRows: MessageButtonOptions[][] = normalizedUI.map((row) => - row.map((component) => component.toDiscordComponent(buttonListeners)) - ); + const configInRows: (MessageButtonOptions | MessageSelectMenuOptions)[][] = + normalizedUI.map((row) => + row.map((component) => + component.toDiscordComponent({ buttonListeners, selectMenuListeners }) + ) + ); // validate row constraints configInRows.forEach((row) => { + if (row.find((config) => config.type === 'SELECT_MENU') && row.length > 1) { + throw new Error( + 'Rows with select menus cannot contain other elements!' + ); + } if (row.length > 5) { throw new Error( 'Rows cannot have more than 5 elements!\n' + - `Row containing "${row.map((x) => x.label).join(' ')}" is invalid.` + // this cast should be safe as the above if covers select menus in rows + `Row containing "${row + .map((x) => (x as MessageButtonOptions).label) + .join(' ')}" is invalid.` ); } }); From 1ce08dd24283a214130a399cb3d307e6db3366af Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 00:51:27 -0400 Subject: [PATCH 23/50] Extract validation to functions --- src/UI.ts | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/UI.ts b/src/UI.ts index 6673ba5..a48b48f 100644 --- a/src/UI.ts +++ b/src/UI.ts @@ -97,26 +97,36 @@ export function toComponents( ); // validate row constraints configInRows.forEach((row) => { - if (row.find((config) => config.type === 'SELECT_MENU') && row.length > 1) { - throw new Error( - 'Rows with select menus cannot contain other elements!' - ); - } - if (row.length > 5) { - throw new Error( - 'Rows cannot have more than 5 elements!\n' + - // this cast should be safe as the above if covers select menus in rows - `Row containing "${row - .map((x) => (x as MessageButtonOptions).label) - .join(' ')}" is invalid.` - ); - } + validateSelectMenuAlone(row); + validateMaxLength(row); }); return configInRows.map((row) => new MessageActionRow().addComponents(...row) ); } +function validateSelectMenuAlone( + row: (MessageButtonOptions | MessageSelectMenuOptions)[] +) { + if (row.find((config) => config.type === 'SELECT_MENU') && row.length > 1) { + throw new Error('Rows with select menus cannot contain other elements!'); + } +} + +function validateMaxLength( + row: (MessageSelectMenuOptions | MessageSelectMenuOptions)[] +) { + if (row.length > 5) { + throw new Error( + 'Rows cannot have more than 5 elements!\n' + + // this cast should be safe as validateSelectMenuAlone covers select menus in rows + `Row containing "${row + .map((x) => (x as MessageButtonOptions).label) + .join(' ')}" is invalid.` + ); + } +} + function getID(label: string, componentType: string): string { const uuid: string = uuidv4(); return `${label}$${componentType}$${uuid}`; From 65784e5fadca3b8ee1c65f5c1b15cebe116fb659 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 01:14:00 -0400 Subject: [PATCH 24/50] Reorganize UI module --- src/ButtonHandler.ts | 3 -- src/Client.ts | 3 +- src/UI/DispatchButton.ts | 34 ++++++++++++++++ src/UI/DispatchLinkButton.ts | 21 ++++++++++ src/UI/DispatchSelectMenu.ts | 29 ++++++++++++++ src/{ => UI}/UI.ts | 78 ++---------------------------------- src/UI/index.ts | 4 ++ src/index.ts | 1 - 8 files changed, 93 insertions(+), 80 deletions(-) delete mode 100644 src/ButtonHandler.ts create mode 100644 src/UI/DispatchButton.ts create mode 100644 src/UI/DispatchLinkButton.ts create mode 100644 src/UI/DispatchSelectMenu.ts rename src/{ => UI}/UI.ts (56%) create mode 100644 src/UI/index.ts diff --git a/src/ButtonHandler.ts b/src/ButtonHandler.ts deleted file mode 100644 index 337079b..0000000 --- a/src/ButtonHandler.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ButtonInteraction } from 'discord.js'; - -export type ButtonHandler = (interaction: ButtonInteraction) => void | Promise; diff --git a/src/Client.ts b/src/Client.ts index 20fc079..b75547c 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -14,8 +14,7 @@ import discord, { import { dispatch } from './dispatch'; import { loadCommands } from './loadCommands'; import { Command } from './Command'; -import { ButtonHandler } from './ButtonHandler'; -import { SelectMenuHandler, toComponents, UIComponent } from './UI'; +import { ButtonHandler, SelectMenuHandler, toComponents, UIComponent } from './UI'; export default class Client extends discord.Client { /** diff --git a/src/UI/DispatchButton.ts b/src/UI/DispatchButton.ts new file mode 100644 index 0000000..852f6c3 --- /dev/null +++ b/src/UI/DispatchButton.ts @@ -0,0 +1,34 @@ +import { + ButtonInteraction, + MessageButtonOptions, + MessageButtonStyleResolvable, +} from 'discord.js'; +import { getID } from './UI'; + +export type ButtonHandler = ( + interaction: ButtonInteraction +) => void | Promise; + +export type ButtonOptions = Omit< + MessageButtonOptions, + 'customId' | 'style' | 'url' +> & { + style: Exclude; + onClick: ButtonHandler; +}; + +export class DispatchButton { + constructor(readonly options: ButtonOptions) {} + + toDiscordComponent({ + buttonListeners, + }: { + buttonListeners: Map; + }): MessageButtonOptions { + const { onClick, ...options } = this.options; + // nonlink buttons must have a customId + const id = getID(this.options.label ?? '', 'button'); + buttonListeners.set(id, onClick); + return { ...options, type: 'BUTTON', customId: id }; + } +} diff --git a/src/UI/DispatchLinkButton.ts b/src/UI/DispatchLinkButton.ts new file mode 100644 index 0000000..1134147 --- /dev/null +++ b/src/UI/DispatchLinkButton.ts @@ -0,0 +1,21 @@ +import { MessageButtonOptions } from 'discord.js'; + +export type LinkButtonOptions = Omit< + MessageButtonOptions, + 'customId' | 'style' | 'url' +> & { + url: string; +}; + +export class DispatchLinkButton { + constructor(readonly options: LinkButtonOptions) {} + + toDiscordComponent(): MessageButtonOptions { + // we override style in case the user omitted it + return { + ...this.options, + type: 'BUTTON', + style: 'LINK', + }; + } +} diff --git a/src/UI/DispatchSelectMenu.ts b/src/UI/DispatchSelectMenu.ts new file mode 100644 index 0000000..4833877 --- /dev/null +++ b/src/UI/DispatchSelectMenu.ts @@ -0,0 +1,29 @@ +import { MessageSelectMenuOptions, SelectMenuInteraction } from 'discord.js'; +import { getID } from './UI'; + +export type SelectMenuOptions = Omit & { + onSelect: SelectMenuHandler; +}; + +export type SelectMenuHandler = ( + interaction: SelectMenuInteraction +) => void | Promise; + +export class DispatchSelectMenu { + constructor(readonly options: SelectMenuOptions) {} + + toDiscordComponent({ + selectMenuListeners, + }: { + selectMenuListeners: Map; + }): MessageSelectMenuOptions { + const { onSelect, ...options } = this.options; + const id = getID(this.options.placeholder ?? '', 'select'); + selectMenuListeners.set(id, onSelect); + return { + ...options, + type: 'SELECT_MENU', + customId: id, + }; + } +} diff --git a/src/UI.ts b/src/UI/UI.ts similarity index 56% rename from src/UI.ts rename to src/UI/UI.ts index a48b48f..b2aff69 100644 --- a/src/UI.ts +++ b/src/UI/UI.ts @@ -1,88 +1,18 @@ import { MessageActionRow, MessageButtonOptions, - MessageButtonStyleResolvable, MessageSelectMenuOptions, - SelectMenuInteraction, } from 'discord.js'; -import { ButtonHandler } from './ButtonHandler'; import { v4 as uuidv4 } from 'uuid'; +import { ButtonHandler, DispatchButton } from './DispatchButton'; +import { DispatchLinkButton } from './DispatchLinkButton'; +import { DispatchSelectMenu, SelectMenuHandler } from './DispatchSelectMenu'; export type UIComponent = | DispatchButton | DispatchLinkButton | DispatchSelectMenu; -export type ButtonOptions = Omit< - MessageButtonOptions, - 'customId' | 'style' | 'url' -> & { - style: Exclude; - onClick: ButtonHandler; -}; - -export type LinkButtonOptions = Omit< - MessageButtonOptions, - 'customId' | 'style' | 'url' -> & { - url: string; -}; - -export class DispatchButton { - constructor(readonly options: ButtonOptions) {} - - toDiscordComponent({ - buttonListeners, - }: { - buttonListeners: Map; - }): MessageButtonOptions { - const { onClick, ...options } = this.options; - // nonlink buttons must have a customId - const id = getID(this.options.label ?? '', 'button'); - buttonListeners.set(id, onClick); - return { ...options, type: 'BUTTON', customId: id }; - } -} - -export class DispatchLinkButton { - constructor(readonly options: LinkButtonOptions) {} - - toDiscordComponent(): MessageButtonOptions { - // we override style in case the user omitted it - return { - ...this.options, - type: 'BUTTON', - style: 'LINK', - }; - } -} - -export type SelectMenuOptions = Omit & { - onSelect: SelectMenuHandler; -}; -export type SelectMenuHandler = ( - interaction: SelectMenuInteraction -) => void | Promise; - -export class DispatchSelectMenu { - constructor(readonly options: SelectMenuOptions) {} - - toDiscordComponent({ - selectMenuListeners, - }: { - selectMenuListeners: Map; - }): MessageSelectMenuOptions { - const { onSelect, ...options } = this.options; - const id = getID(this.options.placeholder ?? '', 'select'); - selectMenuListeners.set(id, onSelect); - return { - ...options, - type: 'SELECT_MENU', - customId: id, - }; - } -} - export function toComponents( components: UIComponent | UIComponent[] | UIComponent[][], buttonListeners: Map, @@ -127,7 +57,7 @@ function validateMaxLength( } } -function getID(label: string, componentType: string): string { +export function getID(label: string, componentType: string): string { const uuid: string = uuidv4(); return `${label}$${componentType}$${uuid}`; } diff --git a/src/UI/index.ts b/src/UI/index.ts new file mode 100644 index 0000000..3ab0d82 --- /dev/null +++ b/src/UI/index.ts @@ -0,0 +1,4 @@ +export * from './DispatchButton'; +export * from './DispatchLinkButton'; +export * from './DispatchSelectMenu'; +export { toComponents, UIComponent } from './UI'; diff --git a/src/index.ts b/src/index.ts index a5b475a..c4e6a7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -export * from './ButtonHandler'; export { default as Client } from './Client'; export * from './Command'; export * from './dispatch'; From cec8af0fd076906195401c47e24435a99660f282 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 18:38:05 -0400 Subject: [PATCH 25/50] Merge button types to a single class --- src/UI/Button.ts | 54 ++++++++++++++++++++++++++++++++++++ src/UI/DispatchButton.ts | 34 ----------------------- src/UI/DispatchLinkButton.ts | 21 -------------- src/UI/UI.ts | 8 ++---- src/UI/index.ts | 3 +- 5 files changed, 57 insertions(+), 63 deletions(-) create mode 100644 src/UI/Button.ts delete mode 100644 src/UI/DispatchButton.ts delete mode 100644 src/UI/DispatchLinkButton.ts diff --git a/src/UI/Button.ts b/src/UI/Button.ts new file mode 100644 index 0000000..056b738 --- /dev/null +++ b/src/UI/Button.ts @@ -0,0 +1,54 @@ +import { + BaseButtonOptions, + ButtonInteraction, + MessageButtonOptions, + MessageButtonStyleResolvable, +} from 'discord.js'; +import { MessageButtonStyles } from 'discord.js/typings/enums'; +import { getID } from './UI'; + +export type ButtonHandler = ( + interaction: ButtonInteraction +) => void | Promise; + +export type ButtonOptions = BaseButtonOptions & { + style: Exclude< + MessageButtonStyleResolvable, + 'LINK' | MessageButtonStyles.LINK + >; + onClick: ButtonHandler; +}; + +export type LinkButtonOptions = BaseButtonOptions & { + style: 'LINK' | MessageButtonStyles.LINK; + url: string; +}; + +function isLinkButtonOptions( + options: LinkButtonOptions | ButtonOptions +): options is LinkButtonOptions { + return options.style === 'LINK'; +} + +export class Button { + constructor(readonly options: ButtonOptions | LinkButtonOptions) {} + + toDiscordComponent({ + buttonListeners, + }: { + buttonListeners: Map; + }): MessageButtonOptions { + if (isLinkButtonOptions(this.options)) { + return { + ...this.options, + type: 'BUTTON', + }; + } else { + const { onClick, ...options } = this.options; + // nonlink buttons must have a customId + const id = getID(options.label ?? '', 'button'); + buttonListeners.set(id, onClick); + return { ...options, type: 'BUTTON', customId: id }; + } + } +} diff --git a/src/UI/DispatchButton.ts b/src/UI/DispatchButton.ts deleted file mode 100644 index 852f6c3..0000000 --- a/src/UI/DispatchButton.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - ButtonInteraction, - MessageButtonOptions, - MessageButtonStyleResolvable, -} from 'discord.js'; -import { getID } from './UI'; - -export type ButtonHandler = ( - interaction: ButtonInteraction -) => void | Promise; - -export type ButtonOptions = Omit< - MessageButtonOptions, - 'customId' | 'style' | 'url' -> & { - style: Exclude; - onClick: ButtonHandler; -}; - -export class DispatchButton { - constructor(readonly options: ButtonOptions) {} - - toDiscordComponent({ - buttonListeners, - }: { - buttonListeners: Map; - }): MessageButtonOptions { - const { onClick, ...options } = this.options; - // nonlink buttons must have a customId - const id = getID(this.options.label ?? '', 'button'); - buttonListeners.set(id, onClick); - return { ...options, type: 'BUTTON', customId: id }; - } -} diff --git a/src/UI/DispatchLinkButton.ts b/src/UI/DispatchLinkButton.ts deleted file mode 100644 index 1134147..0000000 --- a/src/UI/DispatchLinkButton.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { MessageButtonOptions } from 'discord.js'; - -export type LinkButtonOptions = Omit< - MessageButtonOptions, - 'customId' | 'style' | 'url' -> & { - url: string; -}; - -export class DispatchLinkButton { - constructor(readonly options: LinkButtonOptions) {} - - toDiscordComponent(): MessageButtonOptions { - // we override style in case the user omitted it - return { - ...this.options, - type: 'BUTTON', - style: 'LINK', - }; - } -} diff --git a/src/UI/UI.ts b/src/UI/UI.ts index b2aff69..f163021 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -4,14 +4,10 @@ import { MessageSelectMenuOptions, } from 'discord.js'; import { v4 as uuidv4 } from 'uuid'; -import { ButtonHandler, DispatchButton } from './DispatchButton'; -import { DispatchLinkButton } from './DispatchLinkButton'; +import { ButtonHandler, Button } from './Button'; import { DispatchSelectMenu, SelectMenuHandler } from './DispatchSelectMenu'; -export type UIComponent = - | DispatchButton - | DispatchLinkButton - | DispatchSelectMenu; +export type UIComponent = Button | DispatchSelectMenu; export function toComponents( components: UIComponent | UIComponent[] | UIComponent[][], diff --git a/src/UI/index.ts b/src/UI/index.ts index 3ab0d82..b907b64 100644 --- a/src/UI/index.ts +++ b/src/UI/index.ts @@ -1,4 +1,3 @@ -export * from './DispatchButton'; -export * from './DispatchLinkButton'; +export * from './Button'; export * from './DispatchSelectMenu'; export { toComponents, UIComponent } from './UI'; From 069a012b68c11fe8a854491649299a2b7d00e3d2 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 19:03:11 -0400 Subject: [PATCH 26/50] Simplify typings --- src/UI/Button.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/UI/Button.ts b/src/UI/Button.ts index 056b738..b651c6b 100644 --- a/src/UI/Button.ts +++ b/src/UI/Button.ts @@ -1,27 +1,21 @@ import { - BaseButtonOptions, ButtonInteraction, MessageButtonOptions, - MessageButtonStyleResolvable, + MessageButtonStyle, } from 'discord.js'; -import { MessageButtonStyles } from 'discord.js/typings/enums'; import { getID } from './UI'; export type ButtonHandler = ( interaction: ButtonInteraction ) => void | Promise; -export type ButtonOptions = BaseButtonOptions & { - style: Exclude< - MessageButtonStyleResolvable, - 'LINK' | MessageButtonStyles.LINK - >; +export type ButtonOptions = Omit & { + style: Exclude; onClick: ButtonHandler; }; -export type LinkButtonOptions = BaseButtonOptions & { - style: 'LINK' | MessageButtonStyles.LINK; - url: string; +export type LinkButtonOptions = MessageButtonOptions & { + style: 'LINK'; }; function isLinkButtonOptions( From ca92a11a9615879d0d6bd3f170ba580a944cece4 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 19:32:19 -0400 Subject: [PATCH 27/50] Refactor to remove use of class --- src/UI/Button.ts | 65 ++++++++++++++++++++++++------------ src/UI/DispatchSelectMenu.ts | 20 ----------- src/UI/UI.ts | 8 ++--- 3 files changed, 47 insertions(+), 46 deletions(-) diff --git a/src/UI/Button.ts b/src/UI/Button.ts index b651c6b..9ca4c4a 100644 --- a/src/UI/Button.ts +++ b/src/UI/Button.ts @@ -2,8 +2,10 @@ import { ButtonInteraction, MessageButtonOptions, MessageButtonStyle, + MessageSelectMenuOptions, } from 'discord.js'; -import { getID } from './UI'; +import { SelectMenuHandler } from './DispatchSelectMenu'; +import { getID, UIComponent } from './UI'; export type ButtonHandler = ( interaction: ButtonInteraction @@ -19,30 +21,49 @@ export type LinkButtonOptions = MessageButtonOptions & { }; function isLinkButtonOptions( - options: LinkButtonOptions | ButtonOptions + options: UIComponent ): options is LinkButtonOptions { - return options.style === 'LINK'; + return 'url' in options && 'style' in options && options.style === 'LINK'; } -export class Button { - constructor(readonly options: ButtonOptions | LinkButtonOptions) {} +function isRegularButtonOptions( + options: UIComponent +): options is ButtonOptions { + return 'onClick' in options && 'style' in options; +} + +/* for future use if more types are needed +function isSelectMenuOptions( + options: UIComponent +): options is SelectMenuOptions { + return 'onSelect' in options; +} +*/ - toDiscordComponent({ - buttonListeners, - }: { - buttonListeners: Map; - }): MessageButtonOptions { - if (isLinkButtonOptions(this.options)) { - return { - ...this.options, - type: 'BUTTON', - }; - } else { - const { onClick, ...options } = this.options; - // nonlink buttons must have a customId - const id = getID(options.label ?? '', 'button'); - buttonListeners.set(id, onClick); - return { ...options, type: 'BUTTON', customId: id }; - } +export function toDiscordComponent( + options: UIComponent, + buttonListeners: Map, + selectMenuListeners: Map +): MessageButtonOptions | MessageSelectMenuOptions { + if (isLinkButtonOptions(options)) { + return { + ...options, + type: 'BUTTON', + }; + } else if (isRegularButtonOptions(options)) { + // nonlink buttons must have a customId + const { onClick, ...buttonOptions } = options; + const id = getID(options.label ?? '', 'button'); + buttonListeners.set(id, onClick); + return { ...buttonOptions, type: 'BUTTON', customId: id }; + } else { + const { onSelect, ...selectOptions } = options; + const id = getID(selectOptions.placeholder ?? '', 'select'); + selectMenuListeners.set(id, onSelect); + return { + ...selectOptions, + type: 'SELECT_MENU', + customId: id, + }; } } diff --git a/src/UI/DispatchSelectMenu.ts b/src/UI/DispatchSelectMenu.ts index 4833877..0d4b233 100644 --- a/src/UI/DispatchSelectMenu.ts +++ b/src/UI/DispatchSelectMenu.ts @@ -1,5 +1,4 @@ import { MessageSelectMenuOptions, SelectMenuInteraction } from 'discord.js'; -import { getID } from './UI'; export type SelectMenuOptions = Omit & { onSelect: SelectMenuHandler; @@ -8,22 +7,3 @@ export type SelectMenuOptions = Omit & { export type SelectMenuHandler = ( interaction: SelectMenuInteraction ) => void | Promise; - -export class DispatchSelectMenu { - constructor(readonly options: SelectMenuOptions) {} - - toDiscordComponent({ - selectMenuListeners, - }: { - selectMenuListeners: Map; - }): MessageSelectMenuOptions { - const { onSelect, ...options } = this.options; - const id = getID(this.options.placeholder ?? '', 'select'); - selectMenuListeners.set(id, onSelect); - return { - ...options, - type: 'SELECT_MENU', - customId: id, - }; - } -} diff --git a/src/UI/UI.ts b/src/UI/UI.ts index f163021..98d1314 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -4,10 +4,10 @@ import { MessageSelectMenuOptions, } from 'discord.js'; import { v4 as uuidv4 } from 'uuid'; -import { ButtonHandler, Button } from './Button'; -import { DispatchSelectMenu, SelectMenuHandler } from './DispatchSelectMenu'; +import { ButtonHandler, ButtonOptions, LinkButtonOptions, toDiscordComponent } from './Button'; +import { SelectMenuHandler, SelectMenuOptions } from './DispatchSelectMenu'; -export type UIComponent = Button | DispatchSelectMenu; +export type UIComponent = ButtonOptions | LinkButtonOptions | SelectMenuOptions; export function toComponents( components: UIComponent | UIComponent[] | UIComponent[][], @@ -18,7 +18,7 @@ export function toComponents( const configInRows: (MessageButtonOptions | MessageSelectMenuOptions)[][] = normalizedUI.map((row) => row.map((component) => - component.toDiscordComponent({ buttonListeners, selectMenuListeners }) + toDiscordComponent(component, buttonListeners, selectMenuListeners) ) ); // validate row constraints From 30e3e3c383164d4020da94be8ddd715c222d6f52 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 19:35:38 -0400 Subject: [PATCH 28/50] Move toDiscordComponent to UI.ts --- src/UI/Button.ts | 38 ++++---------------------------------- src/UI/UI.ts | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/UI/Button.ts b/src/UI/Button.ts index 9ca4c4a..cec8a28 100644 --- a/src/UI/Button.ts +++ b/src/UI/Button.ts @@ -1,11 +1,9 @@ import { ButtonInteraction, MessageButtonOptions, - MessageButtonStyle, - MessageSelectMenuOptions, + MessageButtonStyle } from 'discord.js'; -import { SelectMenuHandler } from './DispatchSelectMenu'; -import { getID, UIComponent } from './UI'; +import {UIComponent} from './UI'; export type ButtonHandler = ( interaction: ButtonInteraction @@ -20,13 +18,13 @@ export type LinkButtonOptions = MessageButtonOptions & { style: 'LINK'; }; -function isLinkButtonOptions( +export function isLinkButtonOptions( options: UIComponent ): options is LinkButtonOptions { return 'url' in options && 'style' in options && options.style === 'LINK'; } -function isRegularButtonOptions( +export function isRegularButtonOptions( options: UIComponent ): options is ButtonOptions { return 'onClick' in options && 'style' in options; @@ -39,31 +37,3 @@ function isSelectMenuOptions( return 'onSelect' in options; } */ - -export function toDiscordComponent( - options: UIComponent, - buttonListeners: Map, - selectMenuListeners: Map -): MessageButtonOptions | MessageSelectMenuOptions { - if (isLinkButtonOptions(options)) { - return { - ...options, - type: 'BUTTON', - }; - } else if (isRegularButtonOptions(options)) { - // nonlink buttons must have a customId - const { onClick, ...buttonOptions } = options; - const id = getID(options.label ?? '', 'button'); - buttonListeners.set(id, onClick); - return { ...buttonOptions, type: 'BUTTON', customId: id }; - } else { - const { onSelect, ...selectOptions } = options; - const id = getID(selectOptions.placeholder ?? '', 'select'); - selectMenuListeners.set(id, onSelect); - return { - ...selectOptions, - type: 'SELECT_MENU', - customId: id, - }; - } -} diff --git a/src/UI/UI.ts b/src/UI/UI.ts index 98d1314..fa40756 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -4,7 +4,13 @@ import { MessageSelectMenuOptions, } from 'discord.js'; import { v4 as uuidv4 } from 'uuid'; -import { ButtonHandler, ButtonOptions, LinkButtonOptions, toDiscordComponent } from './Button'; +import { + ButtonHandler, + ButtonOptions, + isLinkButtonOptions, + isRegularButtonOptions, + LinkButtonOptions, +} from './Button'; import { SelectMenuHandler, SelectMenuOptions } from './DispatchSelectMenu'; export type UIComponent = ButtonOptions | LinkButtonOptions | SelectMenuOptions; @@ -82,3 +88,31 @@ function normalizeUI( } } } + +function toDiscordComponent( + options: UIComponent, + buttonListeners: Map, + selectMenuListeners: Map +): MessageButtonOptions | MessageSelectMenuOptions { + if (isLinkButtonOptions(options)) { + return { + ...options, + type: 'BUTTON', + }; + } else if (isRegularButtonOptions(options)) { + // nonlink buttons must have a customId + const { onClick, ...buttonOptions } = options; + const id = getID(options.label ?? '', 'button'); + buttonListeners.set(id, onClick); + return { ...buttonOptions, type: 'BUTTON', customId: id }; + } else { + const { onSelect, ...selectOptions } = options; + const id = getID(selectOptions.placeholder ?? '', 'select'); + selectMenuListeners.set(id, onSelect); + return { + ...selectOptions, + type: 'SELECT_MENU', + customId: id, + }; + } +} From 4fded03659a51caebc3a628812fef535959de34a Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 19:36:40 -0400 Subject: [PATCH 29/50] Move isSelectMenuOptions --- src/UI/Button.ts | 8 -------- src/UI/DispatchSelectMenu.ts | 7 +++++++ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/UI/Button.ts b/src/UI/Button.ts index cec8a28..e73dfe9 100644 --- a/src/UI/Button.ts +++ b/src/UI/Button.ts @@ -29,11 +29,3 @@ export function isRegularButtonOptions( ): options is ButtonOptions { return 'onClick' in options && 'style' in options; } - -/* for future use if more types are needed -function isSelectMenuOptions( - options: UIComponent -): options is SelectMenuOptions { - return 'onSelect' in options; -} -*/ diff --git a/src/UI/DispatchSelectMenu.ts b/src/UI/DispatchSelectMenu.ts index 0d4b233..2c4e37b 100644 --- a/src/UI/DispatchSelectMenu.ts +++ b/src/UI/DispatchSelectMenu.ts @@ -1,4 +1,5 @@ import { MessageSelectMenuOptions, SelectMenuInteraction } from 'discord.js'; +import { UIComponent } from './UI'; export type SelectMenuOptions = Omit & { onSelect: SelectMenuHandler; @@ -7,3 +8,9 @@ export type SelectMenuOptions = Omit & { export type SelectMenuHandler = ( interaction: SelectMenuInteraction ) => void | Promise; + +export function isSelectMenuOptions( + options: UIComponent +): options is SelectMenuOptions { + return 'onSelect' in options; +} From 0a15681880d2caf7282a6aeee7ee2ee0ed62cc11 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 19:37:40 -0400 Subject: [PATCH 30/50] Rename DispatchSelectMenu.ts --- src/UI/{DispatchSelectMenu.ts => SelectMenu.ts} | 0 src/UI/UI.ts | 2 +- src/UI/index.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/UI/{DispatchSelectMenu.ts => SelectMenu.ts} (100%) diff --git a/src/UI/DispatchSelectMenu.ts b/src/UI/SelectMenu.ts similarity index 100% rename from src/UI/DispatchSelectMenu.ts rename to src/UI/SelectMenu.ts diff --git a/src/UI/UI.ts b/src/UI/UI.ts index fa40756..a7ec64b 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -11,7 +11,7 @@ import { isRegularButtonOptions, LinkButtonOptions, } from './Button'; -import { SelectMenuHandler, SelectMenuOptions } from './DispatchSelectMenu'; +import { SelectMenuHandler, SelectMenuOptions } from './SelectMenu'; export type UIComponent = ButtonOptions | LinkButtonOptions | SelectMenuOptions; diff --git a/src/UI/index.ts b/src/UI/index.ts index b907b64..32cad8a 100644 --- a/src/UI/index.ts +++ b/src/UI/index.ts @@ -1,3 +1,3 @@ export * from './Button'; -export * from './DispatchSelectMenu'; +export * from './SelectMenu'; export { toComponents, UIComponent } from './UI'; From 6473eb27fd7e1849ffb5161cd49cfc9fdea73d31 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 19:41:42 -0400 Subject: [PATCH 31/50] Rename toComponents to toDiscordUI --- src/Client.ts | 4 ++-- src/UI/UI.ts | 2 +- src/UI/index.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index b75547c..82682b6 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -14,7 +14,7 @@ import discord, { import { dispatch } from './dispatch'; import { loadCommands } from './loadCommands'; import { Command } from './Command'; -import { ButtonHandler, SelectMenuHandler, toComponents, UIComponent } from './UI'; +import { ButtonHandler, SelectMenuHandler, toDiscordUI, UIComponent } from './UI'; export default class Client extends discord.Client { /** @@ -145,7 +145,7 @@ export default class Client extends discord.Client { registerUI = ( ui: UIComponent | UIComponent[] | UIComponent[][] ): MessageActionRow[] => { - return toComponents(ui, this.buttonListeners, this.selectMenuListeners); + return toDiscordUI(ui, this.buttonListeners, this.selectMenuListeners); }; } diff --git a/src/UI/UI.ts b/src/UI/UI.ts index a7ec64b..93f37a4 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -15,7 +15,7 @@ import { SelectMenuHandler, SelectMenuOptions } from './SelectMenu'; export type UIComponent = ButtonOptions | LinkButtonOptions | SelectMenuOptions; -export function toComponents( +export function toDiscordUI( components: UIComponent | UIComponent[] | UIComponent[][], buttonListeners: Map, selectMenuListeners: Map diff --git a/src/UI/index.ts b/src/UI/index.ts index 32cad8a..a0d2f44 100644 --- a/src/UI/index.ts +++ b/src/UI/index.ts @@ -1,3 +1,3 @@ export * from './Button'; export * from './SelectMenu'; -export { toComponents, UIComponent } from './UI'; +export { toDiscordUI, UIComponent } from './UI'; From 68b07c944913f35282660f256772637132ad8df2 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 19:42:19 -0400 Subject: [PATCH 32/50] Simplify exports --- src/UI/UI.ts | 2 +- src/UI/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/UI/UI.ts b/src/UI/UI.ts index 93f37a4..95205a7 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -59,7 +59,7 @@ function validateMaxLength( } } -export function getID(label: string, componentType: string): string { +function getID(label: string, componentType: string): string { const uuid: string = uuidv4(); return `${label}$${componentType}$${uuid}`; } diff --git a/src/UI/index.ts b/src/UI/index.ts index a0d2f44..c034b81 100644 --- a/src/UI/index.ts +++ b/src/UI/index.ts @@ -1,3 +1,3 @@ export * from './Button'; export * from './SelectMenu'; -export { toDiscordUI, UIComponent } from './UI'; +export * from './UI'; From 97cd9e9df3974ea81a61ee3e5dddaf521e2cc800 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 19:43:27 -0400 Subject: [PATCH 33/50] Move toDiscordComponent higher in file --- src/UI/UI.ts | 56 ++++++++++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/UI/UI.ts b/src/UI/UI.ts index 95205a7..655f8ac 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -37,6 +37,34 @@ export function toDiscordUI( ); } +function toDiscordComponent( + options: UIComponent, + buttonListeners: Map, + selectMenuListeners: Map +): MessageButtonOptions | MessageSelectMenuOptions { + if (isLinkButtonOptions(options)) { + return { + ...options, + type: 'BUTTON', + }; + } else if (isRegularButtonOptions(options)) { + // nonlink buttons must have a customId + const { onClick, ...buttonOptions } = options; + const id = getID(options.label ?? '', 'button'); + buttonListeners.set(id, onClick); + return { ...buttonOptions, type: 'BUTTON', customId: id }; + } else { + const { onSelect, ...selectOptions } = options; + const id = getID(selectOptions.placeholder ?? '', 'select'); + selectMenuListeners.set(id, onSelect); + return { + ...selectOptions, + type: 'SELECT_MENU', + customId: id, + }; + } +} + function validateSelectMenuAlone( row: (MessageButtonOptions | MessageSelectMenuOptions)[] ) { @@ -88,31 +116,3 @@ function normalizeUI( } } } - -function toDiscordComponent( - options: UIComponent, - buttonListeners: Map, - selectMenuListeners: Map -): MessageButtonOptions | MessageSelectMenuOptions { - if (isLinkButtonOptions(options)) { - return { - ...options, - type: 'BUTTON', - }; - } else if (isRegularButtonOptions(options)) { - // nonlink buttons must have a customId - const { onClick, ...buttonOptions } = options; - const id = getID(options.label ?? '', 'button'); - buttonListeners.set(id, onClick); - return { ...buttonOptions, type: 'BUTTON', customId: id }; - } else { - const { onSelect, ...selectOptions } = options; - const id = getID(selectOptions.placeholder ?? '', 'select'); - selectMenuListeners.set(id, onSelect); - return { - ...selectOptions, - type: 'SELECT_MENU', - customId: id, - }; - } -} From 660f65bf2ac658387b8f5c1e683898d894b75d7a Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 20:09:58 -0400 Subject: [PATCH 34/50] Use label as value in select options by default --- src/UI/SelectMenu.ts | 16 ++++++++++++++-- src/UI/UI.ts | 21 +++++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/UI/SelectMenu.ts b/src/UI/SelectMenu.ts index 2c4e37b..c940b17 100644 --- a/src/UI/SelectMenu.ts +++ b/src/UI/SelectMenu.ts @@ -1,8 +1,20 @@ -import { MessageSelectMenuOptions, SelectMenuInteraction } from 'discord.js'; +import { + MessageSelectMenuOptions, + MessageSelectOptionData, + SelectMenuInteraction, +} from 'discord.js'; import { UIComponent } from './UI'; -export type SelectMenuOptions = Omit & { +export type SelectOptionData = Omit & { + value?: string; +}; + +export type SelectMenuOptions = Omit< + MessageSelectMenuOptions, + 'customId' | 'options' +> & { onSelect: SelectMenuHandler; + options: SelectOptionData[]; }; export type SelectMenuHandler = ( diff --git a/src/UI/UI.ts b/src/UI/UI.ts index 655f8ac..1abaaab 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -2,6 +2,7 @@ import { MessageActionRow, MessageButtonOptions, MessageSelectMenuOptions, + MessageSelectOptionData, } from 'discord.js'; import { v4 as uuidv4 } from 'uuid'; import { @@ -11,7 +12,7 @@ import { isRegularButtonOptions, LinkButtonOptions, } from './Button'; -import { SelectMenuHandler, SelectMenuOptions } from './SelectMenu'; +import { SelectMenuHandler, SelectMenuOptions, SelectOptionData } from './SelectMenu'; export type UIComponent = ButtonOptions | LinkButtonOptions | SelectMenuOptions; @@ -54,11 +55,13 @@ function toDiscordComponent( buttonListeners.set(id, onClick); return { ...buttonOptions, type: 'BUTTON', customId: id }; } else { - const { onSelect, ...selectOptions } = options; + const { onSelect, options: optionOptions, ...selectOptions } = options; + const discordOptionOptions: MessageSelectOptionData[] = optionOptions.map(toDiscordSelectOptionData); const id = getID(selectOptions.placeholder ?? '', 'select'); selectMenuListeners.set(id, onSelect); return { ...selectOptions, + options: discordOptionOptions, type: 'SELECT_MENU', customId: id, }; @@ -116,3 +119,17 @@ function normalizeUI( } } } + +function toDiscordSelectOptionData(option: SelectOptionData): MessageSelectOptionData { + const { value, ...rest } = option; + if (value === undefined) { + return { + ...rest, + value: rest.label + }; + } + return { + ...rest, + value + }; +} From f6793c0af8891c744842b5247bff4081d9a59cc0 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 20:48:31 -0400 Subject: [PATCH 35/50] Remove GuildMember casts and fix typing problem --- src/utils/permissions.ts | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index c1df00e..c173b61 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -72,8 +72,11 @@ export function hasClientFlags(...flags: PermissionResolvable[]): PermissionHand */ export function hasUserFlags(...flags: PermissionResolvable[]): PermissionHandler { return (interaction) => { - - const permissions = interaction.member?.permissions; + if (!interaction.member || !(interaction.member instanceof GuildMember)) { + console.log(`Member invalid! Was ${interaction.member}`); + return false; + } + const permissions = interaction.member.permissions; if (!permissions) { console.log('Could not lookup permissions'); @@ -102,9 +105,10 @@ const hasID = (member: GuildMember, id: Snowflake) => member.roles.cache.has(id) export function allRoleNames(...roles: string[]): PermissionHandler { return (interaction) => { let allowed = true; - // FIXME is this cast safe? - const member = interaction.member as GuildMember; - if (!member) { + + const member = interaction.member; + if (!member || !(member instanceof GuildMember)) { + console.log(`Member invalid! Was ${member}`); return false; } @@ -138,7 +142,12 @@ export function allRoleNames(...roles: string[]): PermissionHandler { export function inRoleNames(...roles: string[]): PermissionHandler { return (interaction) => { // FIXME is this cast safe? - const member = interaction.member as GuildMember; + const member = interaction.member; + if (!member || !(member instanceof GuildMember)) { + console.log(`Member invalid! Was ${member}`); + return false; + } + const allowed = roles.some((roleName) => member.roles.cache.find((role) => role.name === roleName) ); @@ -166,7 +175,12 @@ export function inRoleNames(...roles: string[]): PermissionHandler { */ export function allRoles(...roleIDs: Snowflake[]): PermissionHandler { return (interaction) => { - const member = interaction.member as GuildMember; + const member = interaction.member; + if (!member || !(member instanceof GuildMember)) { + console.log(`Member invalid! Was ${member}`); + return false; + } + let allowed = true; if (!member) { return false; @@ -191,8 +205,9 @@ export function allRoles(...roleIDs: Snowflake[]): PermissionHandler { */ export function inRoles(...roleIDs: Snowflake[]): PermissionHandler { return (interaction) => { - const member = interaction.member as GuildMember; - if (!member) { + const member = interaction.member; + if (!member || !(member instanceof GuildMember)) { + console.log(`Member invalid! Was ${member}`); return false; } From 7cd39e9fddf734ee653e6a487b0d2a0208ae1434 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 21:01:01 -0400 Subject: [PATCH 36/50] Allow for destructuring of interaction methods --- src/Client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Client.ts b/src/Client.ts index ccf7152..e176cfe 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -17,7 +17,7 @@ import { loadCommands } from './loadCommands'; import { Command } from './Command'; import { ButtonHandler, SelectMenuHandler, toDiscordUI, UIComponent } from './UI'; import { toData } from './utils/command'; -import { isEqual } from 'lodash'; +import { bindAll, isEqual } from 'lodash'; export default class Client extends discord.Client { @@ -169,6 +169,7 @@ export default class Client extends discord.Client { // Enable dispatcher. this.on('interactionCreate', (interaction) => { + interaction = bindAll(interaction); if (interaction instanceof CommandInteraction) { dispatch(interaction, commands, this); } From 07ec7b9dda828bcedc26ee044000aeb73bc184af Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 22:23:09 -0400 Subject: [PATCH 37/50] Extract button and select handler logic to module --- src/Client.ts | 97 ++++++------------------------ src/dispatch.ts | 12 ++-- src/registerInteractionListener.ts | 66 ++++++++++++++++++++ 3 files changed, 92 insertions(+), 83 deletions(-) create mode 100644 src/registerInteractionListener.ts diff --git a/src/Client.ts b/src/Client.ts index e176cfe..18b5bbd 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -2,47 +2,29 @@ import discord, { ApplicationCommand, ApplicationCommandData, ApplicationCommandPermissionData, - ButtonInteraction, ClientOptions, Collection, - CommandInteraction, Guild, GuildApplicationCommandPermissionData, - MessageActionRow, - SelectMenuInteraction, Snowflake, } from 'discord.js'; -import { dispatch } from './dispatch'; -import { loadCommands } from './loadCommands'; +import { isEqual } from 'lodash'; import { Command } from './Command'; -import { ButtonHandler, SelectMenuHandler, toDiscordUI, UIComponent } from './UI'; +import { loadCommands } from './loadCommands'; +import { registerInteractionListener } from './registerInteractionListener'; import { toData } from './utils/command'; -import { bindAll, isEqual } from 'lodash'; export default class Client extends discord.Client { - private commands = new Collection(); - /** - * A map from button IDs to handler functions. This is used to implement - * button click handlers. - */ - buttonListeners: Map = new Map(); - /** - * A map from select menu IDs to handler functions. This is used to implement - * select click handlers. - */ - selectMenuListeners: Map = new Map(); - /** * Handles commands for the bot. */ constructor(options: ClientOptions) { super(options); - } + } async syncCommands(commands: Command[]): Promise { - if (!this.isReady()) { throw new Error('This must be used after the client is ready.'); } @@ -52,9 +34,10 @@ export default class Client extends discord.Client { } // Fetch the commands from the server. - const rawCommands = process.env.NODE_ENV === 'development' ? - await this.guilds.cache.get(process.env.GUILD_ID)?.commands.fetch() : - await this.application.commands.fetch(); + const rawCommands = + process.env.NODE_ENV === 'development' + ? await this.guilds.cache.get(process.env.GUILD_ID)?.commands.fetch() + : await this.application.commands.fetch(); if (!rawCommands) { throw new Error('Could not fetch remote commands!'); @@ -63,10 +46,10 @@ export default class Client extends discord.Client { // Normalize all of the commands. const appCommands = new Collection(); - rawCommands.map(toData).forEach(data => appCommands.set(data.name, data)); + rawCommands.map(toData).forEach((data) => appCommands.set(data.name, data)); const clientCommands = new Collection(); - commands.map(toData).forEach(data => clientCommands.set(data.name, data)); + commands.map(toData).forEach((data) => clientCommands.set(data.name, data)); // Helper for whenever there's a diff. const push = async () => { @@ -77,17 +60,19 @@ export default class Client extends discord.Client { // If the length is not the same it's obvious that the commands aren't the same. if (appCommands.size !== clientCommands.size) { - console.log({ appCommands: appCommands.size, commands: clientCommands.size }); + console.log({ + appCommands: appCommands.size, + commands: clientCommands.size, + }); await push(); return; } - // Calculate if theres a diff between the local and remote commands. - const diff = !appCommands.every(appCommand => { + const diff = !appCommands.every((appCommand) => { // Get the name, and get the corresponding command with the same name. const clientCommand = clientCommands.get(appCommand.name); - + // Check if the commands are equal. return isEqual(clientCommand, appCommand); }); @@ -101,9 +86,7 @@ export default class Client extends discord.Client { await push(); } - async pushCommands( - appCommands: ApplicationCommandData[], - ): Promise { + async pushCommands(appCommands: ApplicationCommandData[]): Promise { let guild: Guild | undefined = undefined; if (process.env.GUILD_ID) { guild = this.guilds.cache.get(process.env.GUILD_ID); @@ -153,7 +136,7 @@ export default class Client extends discord.Client { // Load all of the commands in. const commands = await loadCommands(dir, recursive); - commands.forEach(command => { + commands.forEach((command) => { this.commands.set(command.name, command); }); @@ -166,50 +149,8 @@ export default class Client extends discord.Client { // If we get here the client is already ready, so we'll register immediately. await this.syncCommands(commands); } - - // Enable dispatcher. - this.on('interactionCreate', (interaction) => { - interaction = bindAll(interaction); - if (interaction instanceof CommandInteraction) { - dispatch(interaction, commands, this); - } - - if (interaction instanceof ButtonInteraction) { - const handler = this.buttonListeners.get(interaction.customId); - - if (!handler) { - return; - } - - // Run handler. - handler(interaction); - } - - if (interaction instanceof SelectMenuInteraction) { - const handler = this.selectMenuListeners.get(interaction.customId); - - if (!handler) { - return; - } - - handler(interaction); - } - }); + registerInteractionListener(this, commands); } - - /** - * Generates a discord.js `MessageActionRow[]` that can be used in a - * message reply as the `components` argument. Allows use of `onClick` and - * `onSelect` by autogenerating and registering IDs. - * - * @param ui Either a single `UIComponent` or a 1D or 2D array of `UIComponent`s - * @returns a generated `MessageActionRow[]` - */ - registerUI = ( - ui: UIComponent | UIComponent[] | UIComponent[][] - ): MessageActionRow[] => { - return toDiscordUI(ui, this.buttonListeners, this.selectMenuListeners); - }; } function generatePermissionData( diff --git a/src/dispatch.ts b/src/dispatch.ts index 80e2252..36239ff 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -1,11 +1,13 @@ -import { CommandInteraction } from 'discord.js'; +import { CommandInteraction, MessageActionRow } from 'discord.js'; import { Command } from './Command'; -import Client from './Client'; +import { UIComponent } from './UI'; export async function dispatch( interaction: CommandInteraction, commands: Command[], - client: Client + registerUI: ( + ui: UIComponent | UIComponent[] | UIComponent[][] + ) => MessageActionRow[] ): Promise { // FIXME O(n) performance const command = commands.find((c) => c.name === interaction.commandName); @@ -51,8 +53,8 @@ export async function dispatch( try { await command.run({ interaction, - registerUI: client.registerUI, - guild: interaction.guild, + registerUI, + guild: interaction.guild, }); } catch (error) { console.error(error); diff --git a/src/registerInteractionListener.ts b/src/registerInteractionListener.ts new file mode 100644 index 0000000..4e01007 --- /dev/null +++ b/src/registerInteractionListener.ts @@ -0,0 +1,66 @@ +import { + ButtonInteraction, + Client, + CommandInteraction, + MessageActionRow, + SelectMenuInteraction, +} from 'discord.js'; +import { bindAll } from 'lodash'; +import { Command } from './Command'; +import { dispatch } from './dispatch'; +import { + ButtonHandler, + SelectMenuHandler, + toDiscordUI, + UIComponent, +} from './UI'; + +export function registerInteractionListener( + client: Client, + commands: Command[] +): void { + const buttonListeners: Map = new Map(); + const selectMenuListeners: Map = new Map(); + + /** + * Generates a discord.js `MessageActionRow[]` that can be used in a + * message reply as the `components` argument. Allows use of `onClick` and + * `onSelect` by autogenerating and registering IDs. + * + * @param ui Either a single `UIComponent` or a 1D or 2D array of `UIComponent`s + * @returns a generated `MessageActionRow[]` + */ + const registerUI = ( + ui: UIComponent | UIComponent[] | UIComponent[][] + ): MessageActionRow[] => { + return toDiscordUI(ui, buttonListeners, selectMenuListeners); + }; + + client.on('interactionCreate', (interaction) => { + interaction = bindAll(interaction); + if (interaction instanceof CommandInteraction) { + dispatch(interaction, commands, registerUI); + } + + if (interaction instanceof ButtonInteraction) { + const handler = buttonListeners.get(interaction.customId); + + if (!handler) { + return; + } + + // Run handler. + handler(interaction); + } + + if (interaction instanceof SelectMenuInteraction) { + const handler = selectMenuListeners.get(interaction.customId); + + if (!handler) { + return; + } + + handler(interaction); + } + }); +} From 40b72406b8629fdaad294e00eb7021a66def53cd Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sat, 31 Jul 2021 23:00:56 -0400 Subject: [PATCH 38/50] Remove guild argument Consumers should get `interaction.guild` instead. --- package-lock.json | 212 ++++++++++++++++++++++++++++++++++++++-------- package.json | 2 +- src/Command.ts | 3 - src/dispatch.ts | 9 -- 4 files changed, 179 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf31295..7546fed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", "babel-jest": "^27.0.6", - "discord.js": "^13.0.0-dev.t1627732975.331a9d3", + "discord.js": "^13.0.0-dev.t1627778678.74fc23b", "eslint": "^7.30.0", "jest": "^27.0.6", "lodash": "^4.17.21", @@ -1637,12 +1637,15 @@ "dev": true }, "node_modules/@discordjs/builders": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.2.0.tgz", - "integrity": "sha512-TVq7NZBCJrrTRc3CfxOr3IdgY5nrtqVxZ7qDUF1mN6LgxIiOldmFxsSwMrQBzLFVmOwqFyNLKCeblley8UpEuw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.3.0.tgz", + "integrity": "sha512-yFBPqohVAtCWoDTQCYk5ubgmkiRbGpbiR4RfYGHCmV5S2YZc7j8WzfKVksjuy2o5IWRfXFsW6G2Lr+KpW41pEA==", "dev": true, "dependencies": { - "discord-api-types": "^0.18.1", + "@sindresorhus/is": "^4.0.1", + "discord-api-types": "^0.22.0", + "ow": "^0.26.0", + "ts-mixer": "^5.4.1", "tslib": "^2.3.0" }, "engines": { @@ -1650,13 +1653,16 @@ "npm": ">=7.0.0" } }, - "node_modules/@discordjs/builders/node_modules/discord-api-types": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.18.1.tgz", - "integrity": "sha512-hNC38R9ZF4uaujaZQtQfm5CdQO58uhdkoHQAVvMfIL0LgOSZeW575W8H6upngQOuoxWd8tiRII3LLJm9zuQKYg==", + "node_modules/@discordjs/builders/node_modules/@sindresorhus/is": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", + "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", "dev": true, "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, "node_modules/@discordjs/collection": { @@ -3971,27 +3977,27 @@ } }, "node_modules/discord-api-types": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.21.0.tgz", - "integrity": "sha512-x/YpcXK2tS7kQRavirl5/QnYD1U8L3HzCTMkg+gwDEp+IkE8XGCLJHiZZoEKIAvpJFv5XzT5IPsduTIB2ONbuA==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.22.0.tgz", + "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==", "dev": true, "engines": { "node": ">=12" } }, "node_modules/discord.js": { - "version": "13.0.0-dev.t1627732975.331a9d3", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.t1627732975.331a9d3.tgz", - "integrity": "sha512-AOA/jJNzdtkJjjCBuiwVx5Q3cEVBClYB8XPBJ0wdrm6VUu+YNqJJyPFHM1DqyYwtPN8Jw7YPzPEqDVidCOn+Pg==", + "version": "13.0.0-dev.t1627778678.74fc23b", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.t1627778678.74fc23b.tgz", + "integrity": "sha512-djjWBHp3LPK2fYP2SP39fumFixqaoRtaRBdUtqombPltkcp+Cfd8zVwqJe4xr9+tyxva7FQ6k+49cpwxD/Ja2Q==", "dev": true, "dependencies": { - "@discordjs/builders": "^0.2.0", + "@discordjs/builders": "^0.3.0", "@discordjs/collection": "^0.2.1", "@discordjs/form-data": "^3.0.1", "@sapphire/async-queue": "^1.1.4", "@types/ws": "^7.4.5", "abort-controller": "^3.0.0", - "discord-api-types": "^0.21.0", + "discord-api-types": "^0.22.0", "node-fetch": "^2.6.1", "ws": "^7.5.1" }, @@ -7635,6 +7641,12 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8003,6 +8015,65 @@ "node": ">= 0.8.0" } }, + "node_modules/ow": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.26.0.tgz", + "integrity": "sha512-22YUQW9d6oUSCpIQuBV25djtC1uMtpWqmtUYnuh2UHWeNMpppCFCvq3eSBIWWMDbe2UVq26kWYvBHDzOIu5NYg==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.1", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "type-fest": "^1.2.1", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ow/node_modules/@sindresorhus/is": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", + "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/ow/node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "dev": true, + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ow/node_modules/type-fest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.3.0.tgz", + "integrity": "sha512-mYUYkAy6fPatVWtUeCV/qGeGL3IVucmdJOzeAEfwgCJDx8gP0JaW8jn6KQ5xDfPec31e0KXWn5EUOZMhquR1zA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", @@ -9171,6 +9242,12 @@ "node": ">=10" } }, + "node_modules/ts-mixer": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-5.4.1.tgz", + "integrity": "sha512-Zo9HgPCtNouDgJ+LGtrzVOjSg8+7WGQktIKLwAfaNrlOK1mWGlz1ejsAF/YqUEqAGjUTeB5fEg8gH9Aui6w9xA==", + "dev": true + }, "node_modules/ts-node": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.1.0.tgz", @@ -9550,6 +9627,15 @@ "node": ">= 8" } }, + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -10930,19 +11016,22 @@ "dev": true }, "@discordjs/builders": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.2.0.tgz", - "integrity": "sha512-TVq7NZBCJrrTRc3CfxOr3IdgY5nrtqVxZ7qDUF1mN6LgxIiOldmFxsSwMrQBzLFVmOwqFyNLKCeblley8UpEuw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.3.0.tgz", + "integrity": "sha512-yFBPqohVAtCWoDTQCYk5ubgmkiRbGpbiR4RfYGHCmV5S2YZc7j8WzfKVksjuy2o5IWRfXFsW6G2Lr+KpW41pEA==", "dev": true, "requires": { - "discord-api-types": "^0.18.1", + "@sindresorhus/is": "^4.0.1", + "discord-api-types": "^0.22.0", + "ow": "^0.26.0", + "ts-mixer": "^5.4.1", "tslib": "^2.3.0" }, "dependencies": { - "discord-api-types": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.18.1.tgz", - "integrity": "sha512-hNC38R9ZF4uaujaZQtQfm5CdQO58uhdkoHQAVvMfIL0LgOSZeW575W8H6upngQOuoxWd8tiRII3LLJm9zuQKYg==", + "@sindresorhus/is": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", + "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", "dev": true } } @@ -12727,24 +12816,24 @@ } }, "discord-api-types": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.21.0.tgz", - "integrity": "sha512-x/YpcXK2tS7kQRavirl5/QnYD1U8L3HzCTMkg+gwDEp+IkE8XGCLJHiZZoEKIAvpJFv5XzT5IPsduTIB2ONbuA==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.22.0.tgz", + "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==", "dev": true }, "discord.js": { - "version": "13.0.0-dev.t1627732975.331a9d3", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.t1627732975.331a9d3.tgz", - "integrity": "sha512-AOA/jJNzdtkJjjCBuiwVx5Q3cEVBClYB8XPBJ0wdrm6VUu+YNqJJyPFHM1DqyYwtPN8Jw7YPzPEqDVidCOn+Pg==", + "version": "13.0.0-dev.t1627778678.74fc23b", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.t1627778678.74fc23b.tgz", + "integrity": "sha512-djjWBHp3LPK2fYP2SP39fumFixqaoRtaRBdUtqombPltkcp+Cfd8zVwqJe4xr9+tyxva7FQ6k+49cpwxD/Ja2Q==", "dev": true, "requires": { - "@discordjs/builders": "^0.2.0", + "@discordjs/builders": "^0.3.0", "@discordjs/collection": "^0.2.1", "@discordjs/form-data": "^3.0.1", "@sapphire/async-queue": "^1.1.4", "@types/ws": "^7.4.5", "abort-controller": "^3.0.0", - "discord-api-types": "^0.21.0", + "discord-api-types": "^0.22.0", "node-fetch": "^2.6.1", "ws": "^7.5.1" } @@ -15471,6 +15560,12 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -15749,6 +15844,43 @@ "word-wrap": "^1.2.3" } }, + "ow": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.26.0.tgz", + "integrity": "sha512-22YUQW9d6oUSCpIQuBV25djtC1uMtpWqmtUYnuh2UHWeNMpppCFCvq3eSBIWWMDbe2UVq26kWYvBHDzOIu5NYg==", + "dev": true, + "requires": { + "@sindresorhus/is": "^4.0.1", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "type-fest": "^1.2.1", + "vali-date": "^1.0.0" + }, + "dependencies": { + "@sindresorhus/is": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", + "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", + "dev": true + }, + "dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "type-fest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.3.0.tgz", + "integrity": "sha512-mYUYkAy6fPatVWtUeCV/qGeGL3IVucmdJOzeAEfwgCJDx8gP0JaW8jn6KQ5xDfPec31e0KXWn5EUOZMhquR1zA==", + "dev": true + } + } + }, "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", @@ -16619,6 +16751,12 @@ } } }, + "ts-mixer": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-5.4.1.tgz", + "integrity": "sha512-Zo9HgPCtNouDgJ+LGtrzVOjSg8+7WGQktIKLwAfaNrlOK1mWGlz1ejsAF/YqUEqAGjUTeB5fEg8gH9Aui6w9xA==", + "dev": true + }, "ts-node": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.1.0.tgz", @@ -16900,6 +17038,12 @@ } } }, + "vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=", + "dev": true + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index 987a2fe..0e92343 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", "babel-jest": "^27.0.6", - "discord.js": "^13.0.0-dev.t1627732975.331a9d3", + "discord.js": "^13.0.0-dev.t1627778678.74fc23b", "eslint": "^7.30.0", "jest": "^27.0.6", "lodash": "^4.17.21", diff --git a/src/Command.ts b/src/Command.ts index 01a69ca..fc963a9 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -1,7 +1,6 @@ import { ApplicationCommandOptionData, CommandInteraction, - Guild, MessageActionRow, Snowflake, } from 'discord.js'; @@ -40,13 +39,11 @@ export interface Command { run({ interaction, registerUI, - guild, }: { interaction: CommandInteraction; registerUI: ( ui: UIComponent | UIComponent[] | UIComponent[][] ) => MessageActionRow[]; - guild: Guild; }): Promise | void; /** diff --git a/src/dispatch.ts b/src/dispatch.ts index 36239ff..9a47269 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -42,19 +42,10 @@ export async function dispatch( } } - if (interaction.guild === null) { - await interaction.reply({ - content: 'Unexpected null guild property!', - ephemeral: true, - }); - return; - } - try { await command.run({ interaction, registerUI, - guild: interaction.guild, }); } catch (error) { console.error(error); From fe9265c9a37927edadeea6ec4d69b17946d10ce3 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sun, 1 Aug 2021 12:22:15 -0400 Subject: [PATCH 39/50] Fix this binding issue for real --- src/registerInteractionListener.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/registerInteractionListener.ts b/src/registerInteractionListener.ts index 4e01007..22a546d 100644 --- a/src/registerInteractionListener.ts +++ b/src/registerInteractionListener.ts @@ -37,7 +37,7 @@ export function registerInteractionListener( }; client.on('interactionCreate', (interaction) => { - interaction = bindAll(interaction); + interaction = bindAllMethods(interaction); if (interaction instanceof CommandInteraction) { dispatch(interaction, commands, registerUI); } @@ -64,3 +64,24 @@ export function registerInteractionListener( } }); } + +export function bindAllMethods(object: T): T { + return bindAll(object, getAllMethods(object)); +} + +// don't look below here, evil awaits you +function getAllMethods(object: unknown): string[] { + return getAllMethodsHelper(object).filter( + (prop) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prop !== 'constructor' && typeof (object as any)[prop] === 'function' + ); +} + +function getAllMethodsHelper(object: unknown): string[] { + const props = Object.getOwnPropertyNames(object); + if (Object.getPrototypeOf(object) !== null) { + props.push(...getAllMethodsHelper(Object.getPrototypeOf(object))); + } + return props; +} From b0669f0b60f9b2b9f9810dfb7a2fbd572cc7a0ab Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sun, 1 Aug 2021 13:13:28 -0400 Subject: [PATCH 40/50] Rename UIComponent types --- src/UI/Button.ts | 12 ++++++------ src/UI/SelectMenu.ts | 10 +++++----- src/UI/UI.ts | 18 +++++++++--------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/UI/Button.ts b/src/UI/Button.ts index e73dfe9..d581a01 100644 --- a/src/UI/Button.ts +++ b/src/UI/Button.ts @@ -9,23 +9,23 @@ export type ButtonHandler = ( interaction: ButtonInteraction ) => void | Promise; -export type ButtonOptions = Omit & { +export type Button = Omit & { style: Exclude; onClick: ButtonHandler; }; -export type LinkButtonOptions = MessageButtonOptions & { +export type LinkButton = MessageButtonOptions & { style: 'LINK'; }; -export function isLinkButtonOptions( +export function isLinkButton( options: UIComponent -): options is LinkButtonOptions { +): options is LinkButton { return 'url' in options && 'style' in options && options.style === 'LINK'; } -export function isRegularButtonOptions( +export function isRegularButton( options: UIComponent -): options is ButtonOptions { +): options is Button { return 'onClick' in options && 'style' in options; } diff --git a/src/UI/SelectMenu.ts b/src/UI/SelectMenu.ts index c940b17..7a03c64 100644 --- a/src/UI/SelectMenu.ts +++ b/src/UI/SelectMenu.ts @@ -5,24 +5,24 @@ import { } from 'discord.js'; import { UIComponent } from './UI'; -export type SelectOptionData = Omit & { +export type SelectOption = Omit & { value?: string; }; -export type SelectMenuOptions = Omit< +export type SelectMenu = Omit< MessageSelectMenuOptions, 'customId' | 'options' > & { onSelect: SelectMenuHandler; - options: SelectOptionData[]; + options: SelectOption[]; }; export type SelectMenuHandler = ( interaction: SelectMenuInteraction ) => void | Promise; -export function isSelectMenuOptions( +export function isSelectMenu( options: UIComponent -): options is SelectMenuOptions { +): options is SelectMenu { return 'onSelect' in options; } diff --git a/src/UI/UI.ts b/src/UI/UI.ts index 1abaaab..fbbf527 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -7,14 +7,14 @@ import { import { v4 as uuidv4 } from 'uuid'; import { ButtonHandler, - ButtonOptions, - isLinkButtonOptions, - isRegularButtonOptions, - LinkButtonOptions, + Button, + isLinkButton, + isRegularButton, + LinkButton, } from './Button'; -import { SelectMenuHandler, SelectMenuOptions, SelectOptionData } from './SelectMenu'; +import { SelectMenuHandler, SelectMenu, SelectOption } from './SelectMenu'; -export type UIComponent = ButtonOptions | LinkButtonOptions | SelectMenuOptions; +export type UIComponent = Button | LinkButton | SelectMenu; export function toDiscordUI( components: UIComponent | UIComponent[] | UIComponent[][], @@ -43,12 +43,12 @@ function toDiscordComponent( buttonListeners: Map, selectMenuListeners: Map ): MessageButtonOptions | MessageSelectMenuOptions { - if (isLinkButtonOptions(options)) { + if (isLinkButton(options)) { return { ...options, type: 'BUTTON', }; - } else if (isRegularButtonOptions(options)) { + } else if (isRegularButton(options)) { // nonlink buttons must have a customId const { onClick, ...buttonOptions } = options; const id = getID(options.label ?? '', 'button'); @@ -120,7 +120,7 @@ function normalizeUI( } } -function toDiscordSelectOptionData(option: SelectOptionData): MessageSelectOptionData { +function toDiscordSelectOptionData(option: SelectOption): MessageSelectOptionData { const { value, ...rest } = option; if (value === undefined) { return { From bd19207a54b41a7ccf9a36f08b0942f285ee102c Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sun, 1 Aug 2021 13:15:16 -0400 Subject: [PATCH 41/50] Remove old FIXME --- src/utils/permissions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index c173b61..fee341f 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -141,7 +141,6 @@ export function allRoleNames(...roles: string[]): PermissionHandler { */ export function inRoleNames(...roles: string[]): PermissionHandler { return (interaction) => { - // FIXME is this cast safe? const member = interaction.member; if (!member || !(member instanceof GuildMember)) { console.log(`Member invalid! Was ${member}`); From bcc18f28fdd57632d559612a98a5c7776acf1f5b Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sun, 1 Aug 2021 13:21:13 -0400 Subject: [PATCH 42/50] Format code --- src/UI/Button.ts | 12 ++++-------- src/UI/SelectMenu.ts | 4 +--- src/UI/UI.ts | 16 ++++++++++------ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/UI/Button.ts b/src/UI/Button.ts index d581a01..236fc10 100644 --- a/src/UI/Button.ts +++ b/src/UI/Button.ts @@ -1,9 +1,9 @@ import { ButtonInteraction, MessageButtonOptions, - MessageButtonStyle + MessageButtonStyle, } from 'discord.js'; -import {UIComponent} from './UI'; +import { UIComponent } from './UI'; export type ButtonHandler = ( interaction: ButtonInteraction @@ -18,14 +18,10 @@ export type LinkButton = MessageButtonOptions & { style: 'LINK'; }; -export function isLinkButton( - options: UIComponent -): options is LinkButton { +export function isLinkButton(options: UIComponent): options is LinkButton { return 'url' in options && 'style' in options && options.style === 'LINK'; } -export function isRegularButton( - options: UIComponent -): options is Button { +export function isRegularButton(options: UIComponent): options is Button { return 'onClick' in options && 'style' in options; } diff --git a/src/UI/SelectMenu.ts b/src/UI/SelectMenu.ts index 7a03c64..1ac0ffb 100644 --- a/src/UI/SelectMenu.ts +++ b/src/UI/SelectMenu.ts @@ -21,8 +21,6 @@ export type SelectMenuHandler = ( interaction: SelectMenuInteraction ) => void | Promise; -export function isSelectMenu( - options: UIComponent -): options is SelectMenu { +export function isSelectMenu(options: UIComponent): options is SelectMenu { return 'onSelect' in options; } diff --git a/src/UI/UI.ts b/src/UI/UI.ts index fbbf527..4cea2fb 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -6,13 +6,13 @@ import { } from 'discord.js'; import { v4 as uuidv4 } from 'uuid'; import { - ButtonHandler, Button, + ButtonHandler, isLinkButton, isRegularButton, LinkButton, } from './Button'; -import { SelectMenuHandler, SelectMenu, SelectOption } from './SelectMenu'; +import { SelectMenu, SelectMenuHandler, SelectOption } from './SelectMenu'; export type UIComponent = Button | LinkButton | SelectMenu; @@ -56,7 +56,9 @@ function toDiscordComponent( return { ...buttonOptions, type: 'BUTTON', customId: id }; } else { const { onSelect, options: optionOptions, ...selectOptions } = options; - const discordOptionOptions: MessageSelectOptionData[] = optionOptions.map(toDiscordSelectOptionData); + const discordOptionOptions: MessageSelectOptionData[] = optionOptions.map( + toDiscordSelectOptionData + ); const id = getID(selectOptions.placeholder ?? '', 'select'); selectMenuListeners.set(id, onSelect); return { @@ -120,16 +122,18 @@ function normalizeUI( } } -function toDiscordSelectOptionData(option: SelectOption): MessageSelectOptionData { +function toDiscordSelectOptionData( + option: SelectOption +): MessageSelectOptionData { const { value, ...rest } = option; if (value === undefined) { return { ...rest, - value: rest.label + value: rest.label, }; } return { ...rest, - value + value, }; } From 33eea0a369b773dcf35d393003224d3df4b56259 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sun, 1 Aug 2021 13:28:35 -0400 Subject: [PATCH 43/50] Clean up registerInteractionListener --- src/registerInteractionListener.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/registerInteractionListener.ts b/src/registerInteractionListener.ts index 22a546d..fb286f1 100644 --- a/src/registerInteractionListener.ts +++ b/src/registerInteractionListener.ts @@ -40,27 +40,23 @@ export function registerInteractionListener( interaction = bindAllMethods(interaction); if (interaction instanceof CommandInteraction) { dispatch(interaction, commands, registerUI); - } - - if (interaction instanceof ButtonInteraction) { + } else if (interaction instanceof ButtonInteraction) { const handler = buttonListeners.get(interaction.customId); - if (!handler) { + console.log(`Unregistered customId "${interaction.customId}"`); return; } - - // Run handler. handler(interaction); - } - - if (interaction instanceof SelectMenuInteraction) { + } else if (interaction instanceof SelectMenuInteraction) { const handler = selectMenuListeners.get(interaction.customId); - if (!handler) { + console.log(`Unregistered customId "${interaction.customId}"`); return; } - handler(interaction); + } else { + console.log('Unexpected interaction:'); + console.log(interaction); } }); } From e8f7e48176cfe5b13b7c3a361e93a66310aae42a Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sun, 1 Aug 2021 13:33:17 -0400 Subject: [PATCH 44/50] Add docstring to registerInteractionListener --- src/registerInteractionListener.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/registerInteractionListener.ts b/src/registerInteractionListener.ts index fb286f1..68a3f06 100644 --- a/src/registerInteractionListener.ts +++ b/src/registerInteractionListener.ts @@ -15,6 +15,12 @@ import { UIComponent, } from './UI'; +/** + * This module sets up event handling for button and select menu listeners and + * manages incoming interactions, logging any potential problems. + * @param client `Client` object used to register main interaction handler + * @param commands `Command`[] to register to listen for `CommandInteraction`s + */ export function registerInteractionListener( client: Client, commands: Command[] From 512fe4905f16a2ebf3eafe827e459b8bfa54c463 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Sun, 1 Aug 2021 13:35:58 -0400 Subject: [PATCH 45/50] Refactor to inject listener maps --- src/Client.ts | 2 +- src/registerInteractionListener.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 18b5bbd..35d2cae 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -149,7 +149,7 @@ export default class Client extends discord.Client { // If we get here the client is already ready, so we'll register immediately. await this.syncCommands(commands); } - registerInteractionListener(this, commands); + registerInteractionListener(this, commands, new Map(), new Map()); } } diff --git a/src/registerInteractionListener.ts b/src/registerInteractionListener.ts index 68a3f06..b4e2d6f 100644 --- a/src/registerInteractionListener.ts +++ b/src/registerInteractionListener.ts @@ -20,14 +20,17 @@ import { * manages incoming interactions, logging any potential problems. * @param client `Client` object used to register main interaction handler * @param commands `Command`[] to register to listen for `CommandInteraction`s + * @param buttonListeners map from `customId`s to `ButtonHandler`s used to + * dispatch listeners + * @param selectMenuListeners map from `customId`s to `SelectMenuHandler`s used to + * dispatch listeners */ export function registerInteractionListener( client: Client, - commands: Command[] + commands: Command[], + buttonListeners: Map, + selectMenuListeners: Map ): void { - const buttonListeners: Map = new Map(); - const selectMenuListeners: Map = new Map(); - /** * Generates a discord.js `MessageActionRow[]` that can be used in a * message reply as the `components` argument. Allows use of `onClick` and From 7b1c6f13e6308e43bb2aaeb2d046bc6c18ec0315 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Mon, 2 Aug 2021 01:12:03 -0400 Subject: [PATCH 46/50] Create compile time checks for valid UI layout --- src/Command.ts | 6 ++---- src/UI/Button.ts | 6 ++++-- src/UI/UI.ts | 27 ++++++++++----------------- src/dispatch.ts | 6 ++---- src/registerInteractionListener.ts | 4 ++-- 5 files changed, 20 insertions(+), 29 deletions(-) diff --git a/src/Command.ts b/src/Command.ts index fc963a9..e4bb908 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -4,7 +4,7 @@ import { MessageActionRow, Snowflake, } from 'discord.js'; -import { UIComponent } from './UI'; +import { UI } from './UI'; export type PermissionHandler = ( interaction: CommandInteraction @@ -41,9 +41,7 @@ export interface Command { registerUI, }: { interaction: CommandInteraction; - registerUI: ( - ui: UIComponent | UIComponent[] | UIComponent[][] - ) => MessageActionRow[]; + registerUI: (ui: UI) => MessageActionRow[]; }): Promise | void; /** diff --git a/src/UI/Button.ts b/src/UI/Button.ts index 236fc10..a89a6bb 100644 --- a/src/UI/Button.ts +++ b/src/UI/Button.ts @@ -9,7 +9,7 @@ export type ButtonHandler = ( interaction: ButtonInteraction ) => void | Promise; -export type Button = Omit & { +export type RegularButton = Omit & { style: Exclude; onClick: ButtonHandler; }; @@ -18,10 +18,12 @@ export type LinkButton = MessageButtonOptions & { style: 'LINK'; }; +export type Button = RegularButton | LinkButton; + export function isLinkButton(options: UIComponent): options is LinkButton { return 'url' in options && 'style' in options && options.style === 'LINK'; } -export function isRegularButton(options: UIComponent): options is Button { +export function isRegularButton(options: UIComponent): options is RegularButton { return 'onClick' in options && 'style' in options; } diff --git a/src/UI/UI.ts b/src/UI/UI.ts index 4cea2fb..c493a36 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -5,19 +5,17 @@ import { MessageSelectOptionData, } from 'discord.js'; import { v4 as uuidv4 } from 'uuid'; -import { - Button, - ButtonHandler, - isLinkButton, - isRegularButton, - LinkButton, -} from './Button'; +import { Button, ButtonHandler, isLinkButton, isRegularButton } from './Button'; import { SelectMenu, SelectMenuHandler, SelectOption } from './SelectMenu'; -export type UIComponent = Button | LinkButton | SelectMenu; +export type UI = UIComponent | Row | [Row, Row?, Row?, Row?, Row?]; + +export type Row = [Button, Button?, Button?, Button?, Button?] | [SelectMenu]; + +export type UIComponent = Button | SelectMenu; export function toDiscordUI( - components: UIComponent | UIComponent[] | UIComponent[][], + components: UI, buttonListeners: Map, selectMenuListeners: Map ): MessageActionRow[] { @@ -97,9 +95,7 @@ function getID(label: string, componentType: string): string { return `${label}$${componentType}$${uuid}`; } -function normalizeUI( - ui: UIComponent | UIComponent[] | UIComponent[][] -): UIComponent[][] { +function normalizeUI(ui: UI): UIComponent[][] { /* * We allow the user to pass in a single UI element, a row of elements, or * multiple rows of elements. @@ -108,11 +104,8 @@ function normalizeUI( // single item, so we need to wrap in [][] because toComponents expects a UIComponent[][] return [[ui]]; } else { - const maybeArray: UIComponent | UIComponent[] | undefined = ui[0]; - if (maybeArray === undefined) { - // we had an empty single array - return [[]]; - } else if (Array.isArray(maybeArray)) { + const maybeArray: UIComponent | Row = ui[0]; + if (Array.isArray(maybeArray)) { // we cast because it must be a 2d array return ui as UIComponent[][]; } else { diff --git a/src/dispatch.ts b/src/dispatch.ts index 9a47269..06e3737 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -1,13 +1,11 @@ import { CommandInteraction, MessageActionRow } from 'discord.js'; import { Command } from './Command'; -import { UIComponent } from './UI'; +import { UI } from './UI'; export async function dispatch( interaction: CommandInteraction, commands: Command[], - registerUI: ( - ui: UIComponent | UIComponent[] | UIComponent[][] - ) => MessageActionRow[] + registerUI: (ui: UI) => MessageActionRow[] ): Promise { // FIXME O(n) performance const command = commands.find((c) => c.name === interaction.commandName); diff --git a/src/registerInteractionListener.ts b/src/registerInteractionListener.ts index b4e2d6f..a7dc991 100644 --- a/src/registerInteractionListener.ts +++ b/src/registerInteractionListener.ts @@ -12,7 +12,7 @@ import { ButtonHandler, SelectMenuHandler, toDiscordUI, - UIComponent, + UI, } from './UI'; /** @@ -40,7 +40,7 @@ export function registerInteractionListener( * @returns a generated `MessageActionRow[]` */ const registerUI = ( - ui: UIComponent | UIComponent[] | UIComponent[][] + ui: UI ): MessageActionRow[] => { return toDiscordUI(ui, buttonListeners, selectMenuListeners); }; From 8fcb3bb8344c96b6eda3c65c9d9fc37216a7ecd2 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Mon, 2 Aug 2021 01:12:45 -0400 Subject: [PATCH 47/50] Remove unnecessary runtime checks --- src/UI/UI.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/src/UI/UI.ts b/src/UI/UI.ts index c493a36..03babd7 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -26,11 +26,6 @@ export function toDiscordUI( toDiscordComponent(component, buttonListeners, selectMenuListeners) ) ); - // validate row constraints - configInRows.forEach((row) => { - validateSelectMenuAlone(row); - validateMaxLength(row); - }); return configInRows.map((row) => new MessageActionRow().addComponents(...row) ); @@ -68,28 +63,6 @@ function toDiscordComponent( } } -function validateSelectMenuAlone( - row: (MessageButtonOptions | MessageSelectMenuOptions)[] -) { - if (row.find((config) => config.type === 'SELECT_MENU') && row.length > 1) { - throw new Error('Rows with select menus cannot contain other elements!'); - } -} - -function validateMaxLength( - row: (MessageSelectMenuOptions | MessageSelectMenuOptions)[] -) { - if (row.length > 5) { - throw new Error( - 'Rows cannot have more than 5 elements!\n' + - // this cast should be safe as validateSelectMenuAlone covers select menus in rows - `Row containing "${row - .map((x) => (x as MessageButtonOptions).label) - .join(' ')}" is invalid.` - ); - } -} - function getID(label: string, componentType: string): string { const uuid: string = uuidv4(); return `${label}$${componentType}$${uuid}`; From b58f18e360ea3c6a9961445432ba4a46145682d8 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Mon, 2 Aug 2021 16:43:08 -0400 Subject: [PATCH 48/50] Improve hover types in IDE with Simplify --- src/UI/Button.ts | 26 ++++++++++++++++---------- src/UI/SelectMenu.ts | 22 ++++++++++++---------- src/UI/UI.ts | 20 +++++++++++++++++--- src/utils/Simplify.ts | 1 + 4 files changed, 46 insertions(+), 23 deletions(-) create mode 100644 src/utils/Simplify.ts diff --git a/src/UI/Button.ts b/src/UI/Button.ts index a89a6bb..41f2992 100644 --- a/src/UI/Button.ts +++ b/src/UI/Button.ts @@ -3,27 +3,33 @@ import { MessageButtonOptions, MessageButtonStyle, } from 'discord.js'; +import { Simplify } from '../utils/Simplify'; import { UIComponent } from './UI'; export type ButtonHandler = ( interaction: ButtonInteraction ) => void | Promise; -export type RegularButton = Omit & { - style: Exclude; - onClick: ButtonHandler; -}; +export type Button = Simplify< + Omit & { + style: Simplify>; + onClick: ButtonHandler; + } +>; -export type LinkButton = MessageButtonOptions & { - style: 'LINK'; -}; - -export type Button = RegularButton | LinkButton; +export type LinkButton = Simplify< + Omit & { + style: 'LINK'; + url: string; + } +>; export function isLinkButton(options: UIComponent): options is LinkButton { return 'url' in options && 'style' in options && options.style === 'LINK'; } -export function isRegularButton(options: UIComponent): options is RegularButton { +export function isRegularButton( + options: UIComponent +): options is Button { return 'onClick' in options && 'style' in options; } diff --git a/src/UI/SelectMenu.ts b/src/UI/SelectMenu.ts index 1ac0ffb..6d63466 100644 --- a/src/UI/SelectMenu.ts +++ b/src/UI/SelectMenu.ts @@ -3,19 +3,21 @@ import { MessageSelectOptionData, SelectMenuInteraction, } from 'discord.js'; +import { Simplify } from '../utils/Simplify'; import { UIComponent } from './UI'; -export type SelectOption = Omit & { - value?: string; -}; +export type SelectOption = Simplify< + Omit & { + value?: string; + } +>; -export type SelectMenu = Omit< - MessageSelectMenuOptions, - 'customId' | 'options' -> & { - onSelect: SelectMenuHandler; - options: SelectOption[]; -}; +export type SelectMenu = Simplify< + Omit & { + onSelect: SelectMenuHandler; + options: SelectOption[]; + } +>; export type SelectMenuHandler = ( interaction: SelectMenuInteraction diff --git a/src/UI/UI.ts b/src/UI/UI.ts index 03babd7..31c7471 100644 --- a/src/UI/UI.ts +++ b/src/UI/UI.ts @@ -5,14 +5,28 @@ import { MessageSelectOptionData, } from 'discord.js'; import { v4 as uuidv4 } from 'uuid'; -import { Button, ButtonHandler, isLinkButton, isRegularButton } from './Button'; +import { + Button, + ButtonHandler, + isLinkButton, + isRegularButton, + LinkButton, +} from './Button'; import { SelectMenu, SelectMenuHandler, SelectOption } from './SelectMenu'; export type UI = UIComponent | Row | [Row, Row?, Row?, Row?, Row?]; -export type Row = [Button, Button?, Button?, Button?, Button?] | [SelectMenu]; +export type Row = + | [ + Button | LinkButton, + (Button | LinkButton)?, + (Button | LinkButton)?, + (Button | LinkButton)?, + (Button | LinkButton)? + ] + | [SelectMenu]; -export type UIComponent = Button | SelectMenu; +export type UIComponent = Button | LinkButton | SelectMenu; export function toDiscordUI( components: UI, diff --git a/src/utils/Simplify.ts b/src/utils/Simplify.ts new file mode 100644 index 0000000..319cfe2 --- /dev/null +++ b/src/utils/Simplify.ts @@ -0,0 +1 @@ +export type Simplify = { [KeyType in keyof T]: T[KeyType] }; From b84174f183daa1a61beccd41eb0b98c15bc31bdd Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Mon, 2 Aug 2021 17:30:29 -0400 Subject: [PATCH 49/50] Add some tests --- src/__tests__/registerUI.test.ts | 258 +++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 src/__tests__/registerUI.test.ts diff --git a/src/__tests__/registerUI.test.ts b/src/__tests__/registerUI.test.ts new file mode 100644 index 0000000..b0a02ab --- /dev/null +++ b/src/__tests__/registerUI.test.ts @@ -0,0 +1,258 @@ +import { MessageActionRow } from 'discord.js'; +import { Button, LinkButton, toDiscordUI, UI } from '..'; +import { SelectMenu, SelectOption } from '../UI'; + +describe('toDiscordUI()', () => { + let buttonListeners = new Map(); + let selectMenuListeners = new Map(); + const registerUI = (ui: UI) => + toDiscordUI(ui, buttonListeners, selectMenuListeners); + beforeEach(() => { + buttonListeners = new Map(); + selectMenuListeners = new Map(); + }); + test('a single button', () => { + const ui: Button = { + style: 'PRIMARY', + onClick() { + return; + }, + }; + expect(registerUI(ui)).toStrictEqual([ + new MessageActionRow({ + components: [ + { + type: 'BUTTON', + style: 'PRIMARY', + customId: expect.anything(), + }, + ], + }), + ]); + expect(buttonListeners.size).toBe(1); + expect(selectMenuListeners.size).toBe(0); + expect([...buttonListeners.values()][0]).toBe(ui.onClick); + }); + + test('multiple buttons', () => { + const ui: UI = [ + { + style: 'PRIMARY', + onClick() { + return; + }, + }, + { + style: 'LINK', + url: 'https://example.com', + }, + ]; + expect(registerUI(ui)).toStrictEqual([ + new MessageActionRow({ + components: [ + { + type: 'BUTTON', + style: 'PRIMARY', + customId: expect.anything(), + }, + { + type: 'BUTTON', + style: 'LINK', + url: 'https://example.com', + }, + ], + }), + ]); + expect(buttonListeners.size).toBe(1); + expect(selectMenuListeners.size).toBe(0); + }); + + test('link buttons don\'t register a callback', () => { + const ui: LinkButton = { + style: 'LINK', + url: 'https://example.com', + }; + registerUI(ui); + expect(buttonListeners.size).toBe(0); + expect(selectMenuListeners.size).toBe(0); + }); + + test('select menus', () => { + const options: SelectOption[] = [ + { label: '1' }, + { label: '2' }, + { label: '3' }, + { label: '4' }, + ]; + const ui: SelectMenu = { + onSelect() { + return; + }, + options, + }; + expect(registerUI(ui)).toStrictEqual([ + new MessageActionRow({ + components: [ + { + type: 'SELECT_MENU', + customId: expect.anything(), + options: [ + { label: '1', value: '1' }, + { label: '2', value: '2' }, + { label: '3', value: '3' }, + { label: '4', value: '4' }, + ], + }, + ], + }), + ]); + expect(buttonListeners.size).toBe(0); + expect(selectMenuListeners.size).toBe(1); + expect([...selectMenuListeners.values()][0]).toBe(ui.onSelect); + }); + + test('select menu options can override .value', () => { + const options: SelectOption[] = [ + { label: '1' }, + { label: '2', value: 'something else' }, + { label: '3' }, + { label: '4' }, + ]; + const ui: SelectMenu = { + onSelect() { + return; + }, + options, + }; + expect(registerUI(ui)).toStrictEqual([ + new MessageActionRow({ + components: [ + { + type: 'SELECT_MENU', + customId: expect.anything(), + options: [ + { label: '1', value: '1' }, + { label: '2', value: 'something else' }, + { label: '3', value: '3' }, + { label: '4', value: '4' }, + ], + }, + ], + }), + ]); + expect(buttonListeners.size).toBe(0); + expect(selectMenuListeners.size).toBe(1); + expect([...selectMenuListeners.values()][0]).toBe(ui.onSelect); + }); + + test('having both button rows and select menu rows', () => { + const options: SelectOption[] = [ + { label: '1' }, + { label: '2' }, + { label: '3' }, + { label: '4' }, + ]; + const ui: UI = [ + [ + { + style: 'PRIMARY', + onClick() { + return; + }, + }, + { + style: 'LINK', + url: 'https://example.com', + }, + { + style: 'SECONDARY', + onClick() { + return; + }, + }, + ], + [ + { + onSelect() { + return; + }, + options, + }, + ], + [ + { + style: 'LINK', + url: 'https://example.com', + }, + { + style: 'DANGER', + onClick() { + return; + }, + }, + { + style: 'SUCCESS', + onClick() { + return; + }, + }, + ], + ]; + expect(registerUI(ui)).toStrictEqual([ + new MessageActionRow({ + components: [ + { + type: 'BUTTON', + style: 'PRIMARY', + customId: expect.anything(), + }, + { + type: 'BUTTON', + style: 'LINK', + url: 'https://example.com', + }, + { + type: 'BUTTON', + style: 'SECONDARY', + customId: expect.anything(), + }, + ], + }), + new MessageActionRow({ + components: [ + { + type: 'SELECT_MENU', + customId: expect.anything(), + options: [ + { label: '1', value: '1' }, + { label: '2', value: '2' }, + { label: '3', value: '3' }, + { label: '4', value: '4' }, + ], + }, + ], + }), + new MessageActionRow({ + components: [ + { + type: 'BUTTON', + style: 'LINK', + url: 'https://example.com', + }, + { + type: 'BUTTON', + style: 'DANGER', + customId: expect.anything(), + }, + { + type: 'BUTTON', + style: 'SUCCESS', + customId: expect.anything(), + }, + ], + }), + ]); + expect(buttonListeners.size).toBe(4); + expect(selectMenuListeners.size).toBe(1); + }); +}); From 0e354f8a9d23ec0172b50194ffe0116b1df060c4 Mon Sep 17 00:00:00 2001 From: Robert Boyd III Date: Mon, 2 Aug 2021 19:05:33 -0400 Subject: [PATCH 50/50] Inject registerMessageFilters into commands --- src/Client.ts | 12 +----------- src/Command.ts | 4 ++++ src/dispatch.ts | 5 ++++- src/registerInteractionListener.ts | 30 +++++++++++++++++++----------- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 8574587..848d7ef 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -11,13 +11,11 @@ import discord, { import { isEqual } from 'lodash'; import { Command, isCommand } from './Command'; import { loadStructures } from './loaders'; -import { MessageFilter, runMessageFilters } from './messageFilters'; import { registerInteractionListener } from './registerInteractionListener'; import { toData } from './utils/command'; export default class Client extends discord.Client { private commands = new Collection(); - private messageFilters: MessageFilter[] = []; /** * Handles commands for the bot. @@ -129,14 +127,6 @@ export default class Client extends discord.Client { await guild.commands.permissions.set({ fullPermissions }); } - registerMessageFilters(filters: MessageFilter[]): void { - this.messageFilters.push(...filters); - this.on( - 'messageCreate', - async (message) => await runMessageFilters(message, this.messageFilters) - ); - } - /** * Registers the commands to be used by this client. * @param dir The directory to load commands from. @@ -159,7 +149,7 @@ export default class Client extends discord.Client { // If we get here the client is already ready, so we'll register immediately. await this.syncCommands(commands); } - registerInteractionListener(this, commands, new Map(), new Map()); + registerInteractionListener(this, commands, new Map(), new Map(), []); } } diff --git a/src/Command.ts b/src/Command.ts index e4bb908..06ce371 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -4,6 +4,7 @@ import { MessageActionRow, Snowflake, } from 'discord.js'; +import { MessageFilter } from './messageFilters'; import { UI } from './UI'; export type PermissionHandler = ( @@ -35,6 +36,8 @@ export interface Command { * @param args.interaction Interaction object from discord.js * @param args.registerUI **Must be called at most once per message!** * Generates a discord.js compatible UI from Dispatch components. + * @param args.registerMessageFilters Registers a callback that receives all + * messages and deletes a message if the callback returns false */ run({ interaction, @@ -42,6 +45,7 @@ export interface Command { }: { interaction: CommandInteraction; registerUI: (ui: UI) => MessageActionRow[]; + registerMessageFilters: (filters: MessageFilter[]) => void; }): Promise | void; /** diff --git a/src/dispatch.ts b/src/dispatch.ts index 06e3737..b8144c9 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -1,11 +1,13 @@ import { CommandInteraction, MessageActionRow } from 'discord.js'; import { Command } from './Command'; +import { MessageFilter } from './messageFilters'; import { UI } from './UI'; export async function dispatch( interaction: CommandInteraction, commands: Command[], - registerUI: (ui: UI) => MessageActionRow[] + registerUI: (ui: UI) => MessageActionRow[], + registerMessageFilters: (filters: MessageFilter[]) => void ): Promise { // FIXME O(n) performance const command = commands.find((c) => c.name === interaction.commandName); @@ -44,6 +46,7 @@ export async function dispatch( await command.run({ interaction, registerUI, + registerMessageFilters, }); } catch (error) { console.error(error); diff --git a/src/registerInteractionListener.ts b/src/registerInteractionListener.ts index a7dc991..7019d93 100644 --- a/src/registerInteractionListener.ts +++ b/src/registerInteractionListener.ts @@ -8,12 +8,8 @@ import { import { bindAll } from 'lodash'; import { Command } from './Command'; import { dispatch } from './dispatch'; -import { - ButtonHandler, - SelectMenuHandler, - toDiscordUI, - UI, -} from './UI'; +import { MessageFilter, runMessageFilters } from './messageFilters'; +import { ButtonHandler, SelectMenuHandler, toDiscordUI, UI } from './UI'; /** * This module sets up event handling for button and select menu listeners and @@ -24,12 +20,15 @@ import { * dispatch listeners * @param selectMenuListeners map from `customId`s to `SelectMenuHandler`s used to * dispatch listeners + * @param messageFilters callbacks that are invoked on every message to + * decide if the message should be deleted */ export function registerInteractionListener( client: Client, commands: Command[], buttonListeners: Map, - selectMenuListeners: Map + selectMenuListeners: Map, + messageFilters: MessageFilter[] ): void { /** * Generates a discord.js `MessageActionRow[]` that can be used in a @@ -39,16 +38,25 @@ export function registerInteractionListener( * @param ui Either a single `UIComponent` or a 1D or 2D array of `UIComponent`s * @returns a generated `MessageActionRow[]` */ - const registerUI = ( - ui: UI - ): MessageActionRow[] => { + const registerUI = (ui: UI): MessageActionRow[] => { return toDiscordUI(ui, buttonListeners, selectMenuListeners); }; + const registerMessageFilters = (filters: MessageFilter[]): void => { + messageFilters.push(...filters); + }; + + // set up message filters + client.on( + 'messageCreate', + async (message) => await runMessageFilters(message, messageFilters) + ); + + // handle incoming interactions client.on('interactionCreate', (interaction) => { interaction = bindAllMethods(interaction); if (interaction instanceof CommandInteraction) { - dispatch(interaction, commands, registerUI); + dispatch(interaction, commands, registerUI, registerMessageFilters); } else if (interaction instanceof ButtonInteraction) { const handler = buttonListeners.get(interaction.customId); if (!handler) {