From 9adc5eea6e86ee7c89c03df17ff35f8acf165443 Mon Sep 17 00:00:00 2001 From: WatDuhHekBro <44940783+WatDuhHekBro@users.noreply.github.com> Date: Thu, 1 Apr 2021 05:44:44 -0500 Subject: [PATCH] Started attempting to split up core handler --- jest.config.js | 5 +- src/core/eventListeners.ts | 28 +++++ src/core/handler.ts | 117 ++++++++++-------- src/core/libd.ts | 25 +--- src/index.ts | 3 + src/modules/intercept.ts | 7 ++ src/modules/messageEmbed.test.ts | 55 ++++++++ .../{message_embed.ts => messageEmbed.ts} | 32 +++-- 8 files changed, 186 insertions(+), 86 deletions(-) create mode 100644 src/core/eventListeners.ts create mode 100644 src/modules/intercept.ts create mode 100644 src/modules/messageEmbed.test.ts rename src/modules/{message_embed.ts => messageEmbed.ts} (65%) diff --git a/jest.config.js b/jest.config.js index 09097b3..7917d47 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,5 +3,8 @@ module.exports = { testMatch: ["**/*.test.+(ts|tsx)"], transform: { "^.+\\.(ts|tsx)$": "ts-jest" - } + }, + // The environment is the DOM by default, so discord.js fails to load because it's calling a Node-specific function. + // https://github.com/discordjs/discord.js/issues/3971#issuecomment-602010271 + testEnvironment: "node" }; diff --git a/src/core/eventListeners.ts b/src/core/eventListeners.ts new file mode 100644 index 0000000..e32db03 --- /dev/null +++ b/src/core/eventListeners.ts @@ -0,0 +1,28 @@ +import {client} from "../index"; +import {botHasPermission} from "./libd"; +import {Permissions, Message} from "discord.js"; + +// A list of message ID and callback pairs. You get the emote name and ID of the user reacting. +export const unreactEventListeners: Map void> = new Map(); + +// Attached to the client, there can be one event listener attached to a message ID which is executed if present. +client.on("messageReactionRemove", (reaction, user) => { + const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES); + + if (!canDeleteEmotes) { + const callback = unreactEventListeners.get(reaction.message.id); + callback && callback(reaction.emoji.name, user.id); + } +}); + +// A list of "channel-message" and callback pairs. Also, I imagine that the callback will be much more maintainable when discord.js v13 comes out with a dedicated message.referencedMessage property. +// Also, I'm defining it here instead of the message event because the load order screws up if you export it from there. Yeah... I'm starting to notice just how much technical debt has been built up. The command handler needs to be modularized and refactored sooner rather than later. Define all constants in one area then grab from there. +export const replyEventListeners = new Map void>(); + +client.on("message", (message) => { + // If there's an inline reply, fire off that event listener (if it exists). + if (message.reference) { + const reference = message.reference; + replyEventListeners.get(`${reference.channelID}-${reference.messageID}`)?.(message); + } +}); diff --git a/src/core/handler.ts b/src/core/handler.ts index 1de6c1e..2a9fe42 100644 --- a/src/core/handler.ts +++ b/src/core/handler.ts @@ -1,38 +1,43 @@ import {client} from "../index"; -import Command, {loadableCommands} from "../core/command"; -import {hasPermission, getPermissionLevel, getPermissionName} from "../core/permissions"; -import {Permissions} from "discord.js"; -import {getPrefix} from "../core/structures"; -import {replyEventListeners} from "../core/libd"; -import quote from "../modules/message_embed"; -import {Config} from "../core/structures"; +import Command, {loadableCommands} from "./command"; +import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions"; +import {Permissions, Message} from "discord.js"; +import {getPrefix} from "./structures"; +import {Config} from "./structures"; + +/////////// +// Steps // +/////////// +// 1. Someone sends a message in chat. +// 2. Check if bot, then load commands. +// 3. Check if "...". If not, check if "@...". Resolve prefix and cropped message (if possible). +// 4. Test if bot has permission to send messages. +// 5. Once confirmed as a command, resolve the subcommand. +// 6. Check permission level and whether or not it's an endpoint. +// 7. Execute command if all successful. + +// For custom message events that want to cancel this one on certain conditions. +const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot]; + +export function addInterceptRule(handler: (message: Message) => boolean) { + interceptRules.push(handler); +} client.on("message", async (message) => { + for (const shouldIntercept of interceptRules) { + if (shouldIntercept(message)) { + return; + } + } + const commands = await loadableCommands; - if (message.content.toLowerCase().includes("remember to drink water")) { - message.react("🚱"); - } - - // Message Setup // - if (message.author.bot) return; - - // If there's an inline reply, fire off that event listener (if it exists). - if (message.reference) { - const reference = message.reference; - replyEventListeners.get(`${reference.channelID}-${reference.messageID}`)?.(message); - } - let prefix = getPrefix(message.guild); const originalPrefix = prefix; let exitEarly = !message.content.startsWith(prefix); const clientUser = message.client.user; let usesBotSpecificPrefix = false; - if (!message.content.startsWith(prefix)) { - return quote(message); - } - // If the client user exists, check if it starts with the bot-specific prefix. if (clientUser) { // If the prefix starts with the bot-specific prefix, go off that instead (these two options must mutually exclude each other). @@ -87,34 +92,8 @@ client.on("message", async (message) => { ); // Subcommand Recursion // - let command = commands.get(header); - if (!command) return console.warn(`Command "${header}" was called but for some reason it's still undefined!`); - const params: any[] = []; - let isEndpoint = false; - let permLevel = command.permission ?? 0; - - for (let param of args) { - if (command.endpoint) { - if (command.subcommands.size > 0 || command.user || command.number || command.any) - console.warn(`An endpoint cannot have subcommands! Check ${originalPrefix}${header} again.`); - isEndpoint = true; - break; - } - - const type = command.resolve(param); - command = command.get(param); - permLevel = command.permission ?? permLevel; - - if (type === Command.TYPES.USER) { - const id = param.match(/\d+/g)![0]; - try { - params.push(await message.client.users.fetch(id)); - } catch (error) { - return message.channel.send(`No user found by the ID \`${id}\`!`); - } - } else if (type === Command.TYPES.NUMBER) params.push(Number(param)); - else if (type !== Command.TYPES.SUBCOMMAND) params.push(param); - } + let command = commands.get(header)!; + //resolveSubcommand() if (!message.member) return console.warn("This command was likely called from a DM channel meaning the member object is null."); @@ -147,6 +126,40 @@ client.on("message", async (message) => { }); }); +// Takes a base command and a list of string parameters and returns: +// - The resolved subcommand +// - The resolved parameters +// - Whether or not an endpoint has been broken +// - The permission level required +async function resolveSubcommand(command: Command, args: string[]): [Command, any[], boolean, number] { + const params: any[] = []; + let isEndpoint = false; + let permLevel = command.permission ?? 0; + + for (const param of args) { + if (command.endpoint) { + if (command.subcommands.size > 0 || command.user || command.number || command.any) + console.warn("An endpoint cannot have subcommands!"); + isEndpoint = true; + break; + } + + const type = command.resolve(param); + command = command.get(param); + permLevel = command.permission ?? permLevel; + + if (type === Command.TYPES.USER) { + const id = param.match(/\d+/g)![0]; + try { + params.push(await message.client.users.fetch(id)); + } catch (error) { + return message.channel.send(`No user found by the ID \`${id}\`!`); + } + } else if (type === Command.TYPES.NUMBER) params.push(Number(param)); + else if (type !== Command.TYPES.SUBCOMMAND) params.push(param); + } +} + client.once("ready", () => { if (client.user) { console.ready(`Logged in as ${client.user.username}#${client.user.discriminator}.`); diff --git a/src/core/libd.ts b/src/core/libd.ts index 71ba70a..3746b6a 100644 --- a/src/core/libd.ts +++ b/src/core/libd.ts @@ -9,20 +9,9 @@ import { NewsChannel, MessageOptions } from "discord.js"; -import {client} from "../index"; +import {unreactEventListeners, replyEventListeners} from "./eventListeners"; -// A list of message ID and callback pairs. You get the emote name and ID of the user reacting. -const eventListeners: Map void> = new Map(); - -// Attached to the client, there can be one event listener attached to a message ID which is executed if present. -client.on("messageReactionRemove", (reaction, user) => { - const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES); - - if (!canDeleteEmotes) { - const callback = eventListeners.get(reaction.message.id); - callback && callback(reaction.emoji.name, user.id); - } -}); +export type SingleMessageOptions = MessageOptions & {split?: false}; export function botHasPermission(guild: Guild | null, permission: number): boolean { return !!guild?.me?.hasPermission(permission); @@ -36,7 +25,7 @@ export async function paginate( channel: TextChannel | DMChannel | NewsChannel, senderID: string, total: number, - callback: (page: number, hasMultiplePages: boolean) => MessageOptions & {split?: false}, + callback: (page: number, hasMultiplePages: boolean) => SingleMessageOptions, duration = 60000 ) { const hasMultiplePages = total > 1; @@ -70,7 +59,7 @@ export async function paginate( // Listen for reactions and call the handler. let backwardsReaction = await message.react(BACKWARDS_EMOJI); let forwardsReaction = await message.react(FORWARDS_EMOJI); - eventListeners.set(message.id, handle); + unreactEventListeners.set(message.id, handle); const collector = message.createReactionCollector( (reaction, user) => { if (user.id === senderID) { @@ -91,7 +80,7 @@ export async function paginate( // When time's up, remove the bot's own reactions. collector.on("end", () => { - eventListeners.delete(message.id); + unreactEventListeners.delete(message.id); backwardsReaction.users.remove(message.author); forwardsReaction.users.remove(message.author); }); @@ -127,10 +116,6 @@ export async function prompt(message: Message, senderID: string, onConfirm: () = if (!isDeleted) message.delete(); } -// A list of "channel-message" and callback pairs. Also, I imagine that the callback will be much more maintainable when discord.js v13 comes out with a dedicated message.referencedMessage property. -// Also, I'm defining it here instead of the message event because the load order screws up if you export it from there. Yeah... I'm starting to notice just how much technical debt has been built up. The command handler needs to be modularized and refactored sooner rather than later. Define all constants in one area then grab from there. -export const replyEventListeners = new Map void>(); - // Asks the user for some input using the inline reply feature. The message here is a message you send beforehand. // If the reply is rejected, reply with an error message (when stable support comes from discord.js). // Append "\n*(Note: Make sure to use Discord's inline reply feature or this won't work!)*" in the future? And also the "you can now reply to this message" edit. diff --git a/src/index.ts b/src/index.ts index 497c4f6..5d1f52c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,10 @@ setup.init().then(() => { // Initialize Modules // import "./core/handler"; // Command loading will start as soon as an instance of "core/command" is loaded, which is loaded in "core/handler". +import "./core/eventListeners"; import "./modules/presence"; import "./modules/lavalink"; import "./modules/emoteRegistry"; import "./modules/channelListener"; +import "./modules/intercept"; +import "./modules/messageEmbed"; diff --git a/src/modules/intercept.ts b/src/modules/intercept.ts new file mode 100644 index 0000000..c9a517d --- /dev/null +++ b/src/modules/intercept.ts @@ -0,0 +1,7 @@ +import {client} from "../index"; + +client.on("message", (message) => { + if (message.content.toLowerCase().includes("remember to drink water")) { + message.react("🚱"); + } +}); diff --git a/src/modules/messageEmbed.test.ts b/src/modules/messageEmbed.test.ts new file mode 100644 index 0000000..3508de2 --- /dev/null +++ b/src/modules/messageEmbed.test.ts @@ -0,0 +1,55 @@ +jest.useFakeTimers(); +import {strict as assert} from "assert"; +//import {extractFirstMessageLink} from "./messageEmbed"; + +/*describe("modules/messageEmbed", () => { + describe("extractFirstMessageLink()", () => { + const guildID = "802906483866631183"; + const channelID = "681747101169682147" + const messageID = "996363055050949479"; + const post = `channels/${guildID}/${channelID}/${messageID}`; + const commonUrl = `https://discord.com/channels/${post}`; + const combined = [guildID, channelID, messageID]; + + it('should return work and extract correctly on an isolated link', () => { + const result = extractFirstMessageLink(commonUrl); + assert.deepStrictEqual(result, combined); + }) + + it('should return work and extract correctly on a link within a message', () => { + const result = extractFirstMessageLink(`sample text${commonUrl}, more sample text`); + assert.deepStrictEqual(result, combined); + }) + + it('should return null on "!link"', () => { + const result = extractFirstMessageLink(`just some !${commonUrl} text`); + assert.strictEqual(result, null); + }) + + it('should return null on ""', () => { + const result = extractFirstMessageLink(`just some <${commonUrl}> text`); + assert.strictEqual(result, null); + }) + + it('should return work and extract correctly on " { + const result = extractFirstMessageLink(`just some <${commonUrl} text`); + assert.deepStrictEqual(result, combined); + }) + + it('should return work and extract correctly on "link>"', () => { + const result = extractFirstMessageLink(`just some ${commonUrl}> text`); + assert.deepStrictEqual(result, combined); + }) + + it('should return work and extract correctly on a canary link', () => { + const result = extractFirstMessageLink(`https://canary.discord.com/${post}`); + assert.deepStrictEqual(result, combined); + }) + }) +});*/ + +describe("placeholder", () => { + it("placeholder", async () => { + assert.strictEqual(1, 1); + }); +}); diff --git a/src/modules/message_embed.ts b/src/modules/messageEmbed.ts similarity index 65% rename from src/modules/message_embed.ts rename to src/modules/messageEmbed.ts index e8bdb6d..1d5c1c5 100644 --- a/src/modules/message_embed.ts +++ b/src/modules/messageEmbed.ts @@ -1,19 +1,14 @@ -import {client} from ".."; -import {Message, TextChannel, APIMessage, MessageEmbed} from "discord.js"; +import {client} from "../index"; +import {TextChannel, APIMessage, MessageEmbed} from "discord.js"; import {getPrefix} from "../core/structures"; import {DiscordAPIError} from "discord.js"; -export default async function quote(message: Message) { - if (message.author.bot) return; - // const message_link_regex = message.content.match(/(!)?https?:\/\/\w+\.com\/channels\/(\d+)\/(\d+)\/(\d+)/) - const message_link_regex = message.content.match( - /([?)/ - ); - - if (message_link_regex == null) return; - const [, char, guildID, channelID, messageID] = message_link_regex; - - if (char || message.content.startsWith(getPrefix(message.guild))) return; +client.on("message", async (message) => { + // Only execute if the message is from a user and isn't a command. + if (message.content.startsWith(getPrefix(message.guild)) || message.author.bot) return; + const messageLink = extractFirstMessageLink(message.content); + if (!messageLink) return; + const [guildID, channelID, messageID] = messageLink; try { const channel = client.guilds.cache.get(guildID)?.channels.cache.get(channelID) as TextChannel; @@ -58,4 +53,15 @@ export default async function quote(message: Message) { } return console.error(error); } +}); + +export function extractFirstMessageLink(message: string): [string, string, string] | null { + const messageLinkMatch = message.match( + /([!<])?https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19})(>)?/ + ); + if (messageLinkMatch === null) return null; + const [, leftToken, guildID, channelID, messageID, rightToken] = messageLinkMatch; + // "!link" and "" will cancel the embed request. + if (leftToken === "!" || (leftToken === "<" && rightToken === ">")) return null; + else return [guildID, channelID, messageID]; }