From 4e5f4b4f3a55bd269614add5573c97b1f32f78b4 Mon Sep 17 00:00:00 2001 From: Eric Zhang <36653830+ez314@users.noreply.github.com> Date: Fri, 3 Jun 2022 22:10:10 -0500 Subject: [PATCH 1/3] Add modal interaction framework --- package.json | 4 ++-- src/api/bot.ts | 14 +++++++------ src/api/interaction/modal.ts | 37 +++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/util/manager/interaction.ts | 36 ++++++++++++++++++++------------ 5 files changed, 71 insertions(+), 21 deletions(-) create mode 100644 src/api/interaction/modal.ts diff --git a/package.json b/package.json index 68b2631..0f7e6cd 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,12 @@ "homepage": "https://github.com/jafrilli/acm-bot#readme", "dependencies": { "@discordjs/builders": "^0.12.0", - "@discordjs/rest": "^0.1.0-canary.0", + "@discordjs/rest": "^0.4.1", "@google-cloud/firestore": "^4.13.0", "axios": "^0.21.4", "body-parser": "^1.19.0", "discord-api-types": "^0.23.1", - "discord.js": "^13.6.0", + "discord.js": "^13.7.0", "express": "^4.17.1", "is-url": "^1.2.4", "leeks.js": "^0.0.8", diff --git a/src/api/bot.ts b/src/api/bot.ts index 47584a7..e1d3a71 100644 --- a/src/api/bot.ts +++ b/src/api/bot.ts @@ -27,6 +27,7 @@ export interface Config { slashCommandPath: string; cmCommandPath: string; buttonPath: string; + modalPath: string; eventPath: string; endpointPath: string; sentryDNS: string; @@ -87,17 +88,18 @@ export default class Bot extends Client { this, config.slashCommandPath, config.cmCommandPath, - config.buttonPath + config.buttonPath, + config.modalPath, ), error: new ErrorManager(this), + database: new DatabaseManager(this, config), + firestore: new FirestoreManager(this), verification: new VerificationManager(this), event: new EventManager(this, config.eventPath), indicator: new IndicatorManager(this), - database: new DatabaseManager(this, config), scheduler: new ScheduleManager(this), circle: new CircleManager(this), rero: new ReactionRoleManager(this), - firestore: new FirestoreManager(this), resolve: new ResolveManager(this), express: new ExpressManager(this, config.endpointPath), points: new PointsManager(this), @@ -110,9 +112,9 @@ export default class Bot extends Client { async start(): Promise { await this.login(this.config.token); this.logger.info("Initializing managers..."); - Object.entries(this.managers).forEach(([k, v]) => { - v.init(); - }); + for (const manager of Object.values(this.managers)) { + await manager.init(); + } this.logger.info("Bot started."); } } diff --git a/src/api/interaction/modal.ts b/src/api/interaction/modal.ts new file mode 100644 index 0000000..2d09f58 --- /dev/null +++ b/src/api/interaction/modal.ts @@ -0,0 +1,37 @@ +import { + ApplicationCommandPermissionData, + ModalSubmitInteraction, + Interaction, +} from "discord.js"; +import { SlashCommandBuilder } from "@discordjs/builders"; +import CustomInteraction, { + InteractionConfig, + InteractionContext, +} from "./interaction"; + +export interface ModalInteractionConfig extends InteractionConfig {} + +export interface ModalInteractionContext extends InteractionContext { + interaction: ModalSubmitInteraction; +} + +/** + * No need to register modals, but we do need a way to handle the callbacks + */ +export default abstract class CustomModalInteraction extends CustomInteraction { + protected constructor(config: ModalInteractionConfig) { + super(config); + } + + /** + * Match to customId, either by direct comparison or regex or something else. + * If it matches, expect handleInteraction to be called afterwards. + * @param customId + */ + public abstract matchCustomId(customId: string): boolean; + + /** + * Perform actions for handling the interaction + */ + public abstract handleInteraction(context: ModalInteractionContext): any; +} diff --git a/src/index.ts b/src/index.ts index cc6a2f8..5b0ab31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ const bot: Bot = new Bot({ slashCommandPath: path.join(process.cwd(), "dist", "interaction", "command"), cmCommandPath: path.join(process.cwd(), "dist", "interaction", "contextmenu"), buttonPath: path.join(process.cwd(), "dist", "interaction", "button"), + modalPath: path.join(process.cwd(), "dist", "interaction", "modal"), eventPath: path.join(process.cwd(), "dist", "event"), endpointPath: path.join(process.cwd(), "dist", "endpoint"), responseFormat: settings.responseFormat, diff --git a/src/util/manager/interaction.ts b/src/util/manager/interaction.ts index 0be2c6b..7bad1e6 100644 --- a/src/util/manager/interaction.ts +++ b/src/util/manager/interaction.ts @@ -1,12 +1,7 @@ import { - ButtonInteraction, - Collection, - CommandInteraction, - ContextMenuInteraction, GuildApplicationCommandPermissionData, Interaction, } from "discord.js"; -import path from "path"; import Bot from "../../api/bot"; import Manager from "../../api/manager"; import BaseInteraction from "../../api/interaction/interaction"; @@ -15,28 +10,32 @@ import { Routes } from "discord-api-types/v9"; import SlashCommand from "../../api/interaction/slashcommand"; import CustomButtonInteraction from "../../api/interaction/button"; import ContextMenuCommand from "../../api/interaction/contextmenucommand"; -import { ApplicationCommandType } from "discord-api-types"; +import CustomModalInteraction from "../../api/interaction/modal"; export default class InteractionManager extends Manager { // private readonly interactionPath = process.cwd() + "/dist/interaction/"; private slashCommandPath: string; private cmCommandPath: string; private buttonPath: string; + private modalPath: string; private slashCommands: Map = new Map(); private cmCommands: Map = new Map(); private buttons: Map = new Map(); + private modals: Map = new Map(); constructor( bot: Bot, slashCommandPath: string, cmCommandPath: string, - buttonPath: string + buttonPath: string, + modalPath: string, ) { super(bot); this.slashCommandPath = slashCommandPath; this.cmCommandPath = cmCommandPath; this.buttonPath = buttonPath; + this.modalPath = modalPath; } /** @@ -45,7 +44,7 @@ export default class InteractionManager extends Manager { public init() { // this.loadInteractionHandlers(); this.registerSlashAndContextMenuCommands(); - this.registerButtons(); + this.registerButtonsAndModals(); } /** @@ -65,6 +64,10 @@ export default class InteractionManager extends Manager { handler = [...this.buttons.values()].find((x) => x.matchCustomId(interaction.customId) ) as BaseInteraction; + } else if (interaction.isModalSubmit()) { + handler = [...this.modals.values()].find((x) => + x.matchCustomId(interaction.customId) + ) as BaseInteraction; } else return; // Return if not found @@ -74,9 +77,10 @@ export default class InteractionManager extends Manager { try { await handler.handleInteraction({ bot: this.bot, interaction }); } catch (e: any) { - await interaction.reply( - "Command execution failed. Please contact a bot maintainer..." - ); + await interaction.reply({ + content: "Command execution failed. Please contact a bot maintainer...", + ephemeral: true + }); // Don't throw and let the bot handle this as an unhandled rejection. Instead, // take initiative to handle it as an error so we can see the trace. await this.bot.managers.error.handleErr(e); @@ -161,16 +165,22 @@ export default class InteractionManager extends Manager { } } - private async registerButtons() { + private async registerButtonsAndModals() { try { // Dynamically load source files this.buttons = new Map( DynamicLoader.loadClasses(this.buttonPath).map((sc) => [sc.name, sc]) ); - for (const btn of this.buttons.keys()) { this.bot.logger.info(`Loaded button '${btn}'`); } + + this.modals = new Map( + DynamicLoader.loadClasses(this.modalPath).map((sc) => [sc.name, sc]) + ); + for (const mdl of this.modals.keys()) { + this.bot.logger.info(`Loaded modal '${mdl}'`); + } } catch (error: any) { await this.bot.managers.error.handleErr(error); } From 0e74cab539bd21d0ce52b194549590d2b4742215 Mon Sep 17 00:00:00 2001 From: Eric Zhang <36653830+ez314@users.noreply.github.com> Date: Fri, 3 Jun 2022 22:33:10 -0500 Subject: [PATCH 2/3] Move verification to modals --- src/event/messagecreate.ts | 1 - src/interaction/button/verification.ts | 22 +++++ src/interaction/modal/verification.ts | 23 +++++ src/util/manager/verification.ts | 114 ++++++++++++++++++++----- 4 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 src/interaction/button/verification.ts create mode 100644 src/interaction/modal/verification.ts diff --git a/src/event/messagecreate.ts b/src/event/messagecreate.ts index e4a8b96..8ec8fc9 100644 --- a/src/event/messagecreate.ts +++ b/src/event/messagecreate.ts @@ -9,6 +9,5 @@ export default class MessageCreateEvent extends Event { public async emit(bot: Bot, msg: Message): Promise { await bot.managers.command.handle(msg); - await bot.managers.verification.handle(msg); } } diff --git a/src/interaction/button/verification.ts b/src/interaction/button/verification.ts new file mode 100644 index 0000000..12998cf --- /dev/null +++ b/src/interaction/button/verification.ts @@ -0,0 +1,22 @@ +import CustomButtonInteraction, { + ButtonInteractionContext +} from "../../api/interaction/button"; + +export default class VerificationButton extends CustomButtonInteraction { + public constructor() { + super({ + name: "verification-button", + }); + } + + public matchCustomId(customId: string) { + return customId === "verification-button"; + } + + public async handleInteraction({ + bot, + interaction, + }: ButtonInteractionContext): Promise { + await bot.managers.verification.handleVerificationRequest(interaction); + } +} diff --git a/src/interaction/modal/verification.ts b/src/interaction/modal/verification.ts new file mode 100644 index 0000000..d57596e --- /dev/null +++ b/src/interaction/modal/verification.ts @@ -0,0 +1,23 @@ +import CustomModalInteraction, { + ModalInteractionContext, +} from "../../api/interaction/modal"; + +export default class VerificationModal extends CustomModalInteraction { + public constructor() { + super({ + name: "verification-modal", + }); + } + + public matchCustomId(customId: string) { + return customId === "verification-modal"; + } + + public async handleInteraction({ + bot, + interaction, + }: ModalInteractionContext): Promise { + // Forward to circle handler + await bot.managers.verification.handleVerificationSubmit(interaction); + } +} diff --git a/src/util/manager/verification.ts b/src/util/manager/verification.ts index 243cdbc..d90a2c0 100644 --- a/src/util/manager/verification.ts +++ b/src/util/manager/verification.ts @@ -1,4 +1,10 @@ -import { Message } from "discord.js"; +import { + ButtonInteraction, + GuildMember, MessageActionRow, + MessageButton, Modal, + ModalSubmitInteraction, + TextInputComponent +} from "discord.js"; import Bot from "../../api/bot"; import Manager from "../../api/manager"; @@ -14,28 +20,90 @@ export default class VerificationManager extends Manager { this.bot = bot; } - public init() {} - - public async handle(msg: Message) { - if (msg.guild) { - if (msg.channel.id == this.verificationChannelID && msg.member) { - try { - // Modify member nickname and roles - await msg.member.setNickname(msg.content); - await msg.member.roles.add(this.memberRoleID); - await msg.delete(); - - // Add to firebase - let map: any = {}; - map[msg.member.id] = msg.content; - this.bot.managers.firestore.firestore - ?.collection("discord") - .doc("snowflake_to_name") - .set(map, { merge: true }); - return; - } catch (err: any) { - this.bot.logger.error(err); - } + public async init() { + // Ensure the verification channel's last message has a verification button to the bot + const guild = await this.bot.guilds.fetch(this.bot.settings.guild); + const verificationChannel = await guild.channels.fetch( + this.verificationChannelID + ); + + if ( + verificationChannel === null || + verificationChannel.type != "GUILD_TEXT" + ) { + this.bot.logger.error("Could not resolve verification channel"); + return; + } + + let messages = await verificationChannel.messages.fetch({ limit: 1 }); + messages = messages.filter( + (m) => m.author.id == this.bot.user!.id && m.components.length > 0 + ); + if (messages.size <= 0) { + const actionRow = new MessageActionRow({ + components: [ + new MessageButton({ + label: "Accept rules and verify", + customId: `verification-button`, + style: "SUCCESS", + }), + ], + }); + + await verificationChannel.send({ + components: [actionRow], + }); + } + } + + public async handleVerificationRequest(interaction: ButtonInteraction) { + // Prompt for real name using modal + const nameInput = new TextInputComponent({ + customId: "name", + label: "Full name | Ex: Tee Mock (he/him)", + style: "SHORT", + }); + + const modal = new Modal({ + customId: "verification-modal", + title: "Verify", + components: [ + new MessageActionRow().addComponents([nameInput]), + ], + }); + + await interaction.showModal(modal); + } + + public async handleVerificationSubmit(interaction: ModalSubmitInteraction) { + if (interaction.guild !== null) { + try { + const member = await interaction.guild.members.fetch( + interaction.user.id + ); + const name = interaction.fields.getTextInputValue("name"); + + // Modify member nickname and roles + await member.setNickname(name); + await member.roles.add(this.memberRoleID); + + // Add to firebase + let map: any = {}; + map[member.id] = name; + this.bot.managers.firestore.firestore + ?.collection("discord") + .doc("snowflake_to_name") + .set(map, { merge: true }); + + // Reply + await interaction.reply({ + content: `Thank you for verifying! To join to more channels, visit <#${this.bot.settings.circles.joinChannel}>.`, + ephemeral: true, + }); + + return; + } catch (err: any) { + this.bot.logger.error(err); } } } From 4f2610e1c948a9f12d21f14a312547739573fb5b Mon Sep 17 00:00:00 2001 From: Eric Zhang <36653830+ez314@users.noreply.github.com> Date: Fri, 3 Jun 2022 22:34:59 -0500 Subject: [PATCH 3/3] Autoverify old members and prevent reverify --- src/event/guildmemberadd.ts | 3 +++ src/util/manager/verification.ts | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/event/guildmemberadd.ts b/src/event/guildmemberadd.ts index fc09d30..8a46fcc 100644 --- a/src/event/guildmemberadd.ts +++ b/src/event/guildmemberadd.ts @@ -8,6 +8,9 @@ export default class GuildMemberAddEvent extends Event { } public async emit(bot: Bot, member: GuildMember) { + // If member previously verified, restore their member role + await bot.managers.verification.handleMemberJoin(member); + const embed = new MessageEmbed({ title: `**Welcome to the ACM Discord Server!** 🎉`, author: { diff --git a/src/util/manager/verification.ts b/src/util/manager/verification.ts index d90a2c0..f3563da 100644 --- a/src/util/manager/verification.ts +++ b/src/util/manager/verification.ts @@ -11,16 +11,32 @@ import Manager from "../../api/manager"; export default class VerificationManager extends Manager { private readonly verificationChannelID: string; private readonly memberRoleID: string; + private verified: Map; constructor(bot: Bot) { super(bot); this.verificationChannelID = bot.settings.channels.verification; this.memberRoleID = bot.settings.roles.member; + this.verified = new Map(); this.bot = bot; } public async init() { + // Fetch verified list from firestore + const document = await this.bot.managers.firestore.firestore + ?.collection("discord") + .doc("snowflake_to_name") + .get(); + + if (document?.exists) { + this.verified = new Map(Object.entries(document.data()!)); + } else { + this.bot.logger.error( + "Unable to fetch snowflake to name mapping for verification. Verified users will be able to re-verify." + ); + } + // Ensure the verification channel's last message has a verification button to the bot const guild = await this.bot.guilds.fetch(this.bot.settings.guild); const verificationChannel = await guild.channels.fetch( @@ -56,7 +72,24 @@ export default class VerificationManager extends Manager { } } + public async handleMemberJoin(member: GuildMember) { + // Set nickname and return verified role + if (this.verified.has(member.id)) { + await member.setNickname(this.verified.get(member.id)!); + await member.roles.add(this.memberRoleID); + } + } + public async handleVerificationRequest(interaction: ButtonInteraction) { + // Do not allow verified users to reverify + if (this.verified.has(interaction.user.id)) { + await interaction.reply({ + content: "You've already been verified!", + ephemeral: true, + }); + return; + } + // Prompt for real name using modal const nameInput = new TextInputComponent({ customId: "name", @@ -94,6 +127,10 @@ export default class VerificationManager extends Manager { ?.collection("discord") .doc("snowflake_to_name") .set(map, { merge: true }); + + // Add to local verified list + this.verified.set(member.id, name); + // Reply await interaction.reply({