diff --git a/package-lock.json b/package-lock.json index 9ace155..317a1a4 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", @@ -19,10 +20,11 @@ "@types/jest": "^26.0.24", "@types/lodash": "^4.14.171", "@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", - "discord.js": "^13.0.0-dev.t1627776267.00c2bf8", + "discord.js": "^13.0.0-dev.t1627778678.74fc23b", "eslint": "^7.30.0", "jest": "^27.0.6", "lodash": "^4.17.21", @@ -1635,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": { @@ -1648,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": { @@ -2630,6 +2638,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", @@ -3963,27 +3977,28 @@ } }, "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.t1627776267.00c2bf8", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.t1627776267.00c2bf8.tgz", - "integrity": "sha512-Me2QAXbDEmtb1J1g0keS6mOcnhpZvY47CgCYgpdvRsc1jtE3zdBcvJfqw58JPWCjfZdIIaYkJv+4OrwEqKvaYQ==", + "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==", + "deprecated": "no longer supported", "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" }, @@ -7627,6 +7642,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", @@ -7995,6 +8016,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", @@ -9163,6 +9243,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", @@ -9505,6 +9591,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", @@ -9534,6 +9628,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", @@ -10914,19 +11017,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 } } @@ -11702,6 +11808,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", @@ -12705,24 +12817,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.t1627776267.00c2bf8", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.0-dev.t1627776267.00c2bf8.tgz", - "integrity": "sha512-Me2QAXbDEmtb1J1g0keS6mOcnhpZvY47CgCYgpdvRsc1jtE3zdBcvJfqw58JPWCjfZdIIaYkJv+4OrwEqKvaYQ==", + "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" } @@ -15449,6 +15561,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", @@ -15727,6 +15845,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", @@ -16597,6 +16752,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", @@ -16848,6 +17009,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", @@ -16873,6 +17039,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 50f9174..0e92343 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,11 @@ "@types/jest": "^26.0.24", "@types/lodash": "^4.14.171", "@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", - "discord.js": "^13.0.0-dev.t1627776267.00c2bf8", + "discord.js": "^13.0.0-dev.t1627778678.74fc23b", "eslint": "^7.30.0", "jest": "^27.0.6", "lodash": "^4.17.21", @@ -44,7 +45,8 @@ "typescript": "^4.3.5" }, "dependencies": { - "dotenv": "^10.0.0" + "dotenv": "^10.0.0", + "uuid": "^8.3.2" }, "files": [ "/dist" 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 66a2db6..848d7ef 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -4,33 +4,27 @@ import discord, { ApplicationCommandPermissionData, ClientOptions, Collection, - CommandInteraction, Guild, GuildApplicationCommandPermissionData, - Message, Snowflake, } from 'discord.js'; -import { dispatch } from './dispatch'; -import { loadStructures } from './loaders'; +import { isEqual } from 'lodash'; import { Command, isCommand } from './Command'; +import { loadStructures } from './loaders'; +import { registerInteractionListener } from './registerInteractionListener'; import { toData } from './utils/command'; -import { isEqual } from 'lodash'; -import { MessageFilter, runMessageFilters } from './messageFilters'; export default class Client extends discord.Client { - private commands = new Collection(); - private messageFilters: MessageFilter[] = []; /** * 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.'); } @@ -40,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!'); @@ -51,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 () => { @@ -65,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); }); @@ -89,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); @@ -100,9 +95,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; @@ -134,12 +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('messageUpdate', async (_, message) => await runMessageFilters(message as Message, this.messageFilters)); - 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. @@ -149,7 +136,7 @@ export default class Client extends discord.Client { // Load all of the commands in. const commands = await loadStructures(dir, isCommand, recursive); - commands.forEach(command => { + commands.forEach((command) => { this.commands.set(command.name, command); }); @@ -162,27 +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); } - - // Enable dispatcher. - this.on('interactionCreate', (interaction) => { - if (interaction instanceof CommandInteraction) { - dispatch(interaction, commands); - } - - // FIXME figure out a button/select menu api that - /* - if (interaction instanceof ButtonInteraction) { - const handler = client.buttonListeners.get(interaction.customId); - - if (!handler) { - return; - } - - // Run handler. - handler(interaction); - } - */ - }); + registerInteractionListener(this, commands, new Map(), new Map(), []); } } diff --git a/src/Command.ts b/src/Command.ts index df92477..06ce371 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -1,10 +1,15 @@ import { ApplicationCommandOptionData, CommandInteraction, + MessageActionRow, Snowflake, } from 'discord.js'; +import { MessageFilter } from './messageFilters'; +import { UI } from './UI'; -export type PermissionHandler = (interaction: CommandInteraction) => boolean | string | Promise; +export type PermissionHandler = ( + interaction: CommandInteraction +) => boolean | string | Promise; /** * Represents a the blueprint for a slash commands. @@ -26,10 +31,22 @@ 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 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: CommandInteraction): Promise | void; + run({ + interaction, + registerUI, + }: { + interaction: CommandInteraction; + registerUI: (ui: UI) => MessageActionRow[]; + registerMessageFilters: (filters: MessageFilter[]) => void; + }): Promise | void; /** * The static role permissions for this command. diff --git a/src/UI/Button.ts b/src/UI/Button.ts new file mode 100644 index 0000000..41f2992 --- /dev/null +++ b/src/UI/Button.ts @@ -0,0 +1,35 @@ +import { + ButtonInteraction, + MessageButtonOptions, + MessageButtonStyle, +} from 'discord.js'; +import { Simplify } from '../utils/Simplify'; +import { UIComponent } from './UI'; + +export type ButtonHandler = ( + interaction: ButtonInteraction +) => void | Promise; + +export type Button = Simplify< + Omit & { + style: Simplify>; + onClick: ButtonHandler; + } +>; + +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 Button { + return 'onClick' in options && 'style' in options; +} diff --git a/src/UI/SelectMenu.ts b/src/UI/SelectMenu.ts new file mode 100644 index 0000000..6d63466 --- /dev/null +++ b/src/UI/SelectMenu.ts @@ -0,0 +1,28 @@ +import { + MessageSelectMenuOptions, + MessageSelectOptionData, + SelectMenuInteraction, +} from 'discord.js'; +import { Simplify } from '../utils/Simplify'; +import { UIComponent } from './UI'; + +export type SelectOption = Simplify< + Omit & { + value?: string; + } +>; + +export type SelectMenu = Simplify< + Omit & { + onSelect: SelectMenuHandler; + options: SelectOption[]; + } +>; + +export type SelectMenuHandler = ( + interaction: SelectMenuInteraction +) => void | Promise; + +export function isSelectMenu(options: UIComponent): options is SelectMenu { + return 'onSelect' in options; +} diff --git a/src/UI/UI.ts b/src/UI/UI.ts new file mode 100644 index 0000000..31c7471 --- /dev/null +++ b/src/UI/UI.ts @@ -0,0 +1,119 @@ +import { + MessageActionRow, + MessageButtonOptions, + MessageSelectMenuOptions, + MessageSelectOptionData, +} from 'discord.js'; +import { v4 as uuidv4 } from 'uuid'; +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 | LinkButton, + (Button | LinkButton)?, + (Button | LinkButton)?, + (Button | LinkButton)?, + (Button | LinkButton)? + ] + | [SelectMenu]; + +export type UIComponent = Button | LinkButton | SelectMenu; + +export function toDiscordUI( + components: UI, + buttonListeners: Map, + selectMenuListeners: Map +): MessageActionRow[] { + const normalizedUI = normalizeUI(components); + const configInRows: (MessageButtonOptions | MessageSelectMenuOptions)[][] = + normalizedUI.map((row) => + row.map((component) => + toDiscordComponent(component, buttonListeners, selectMenuListeners) + ) + ); + return configInRows.map((row) => + new MessageActionRow().addComponents(...row) + ); +} + +function toDiscordComponent( + options: UIComponent, + buttonListeners: Map, + selectMenuListeners: Map +): MessageButtonOptions | MessageSelectMenuOptions { + if (isLinkButton(options)) { + return { + ...options, + type: 'BUTTON', + }; + } else if (isRegularButton(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, 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, + }; + } +} + +function getID(label: string, componentType: string): string { + const uuid: string = uuidv4(); + return `${label}$${componentType}$${uuid}`; +} + +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. + */ + if (!Array.isArray(ui)) { + // single item, so we need to wrap in [][] because toComponents expects a UIComponent[][] + return [[ui]]; + } else { + const maybeArray: UIComponent | Row = ui[0]; + 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[]]; + } + } +} + +function toDiscordSelectOptionData( + option: SelectOption +): MessageSelectOptionData { + const { value, ...rest } = option; + if (value === undefined) { + return { + ...rest, + value: rest.label, + }; + } + return { + ...rest, + value, + }; +} diff --git a/src/UI/index.ts b/src/UI/index.ts new file mode 100644 index 0000000..c034b81 --- /dev/null +++ b/src/UI/index.ts @@ -0,0 +1,3 @@ +export * from './Button'; +export * from './SelectMenu'; +export * from './UI'; 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); + }); +}); diff --git a/src/dispatch.ts b/src/dispatch.ts index 6bae159..b8144c9 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -1,9 +1,13 @@ -import { CommandInteraction } from 'discord.js'; +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[] + commands: Command[], + registerUI: (ui: UI) => MessageActionRow[], + registerMessageFilters: (filters: MessageFilter[]) => void ): Promise { // FIXME O(n) performance const command = commands.find((c) => c.name === interaction.commandName); @@ -39,8 +43,12 @@ export async function dispatch( } try { - await command.run(interaction); - } catch(error) { + await command.run({ + interaction, + registerUI, + registerMessageFilters, + }); + } catch (error) { console.error(error); } } diff --git a/src/index.ts b/src/index.ts index 93bb5c9..c4e6a7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,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'; diff --git a/src/registerInteractionListener.ts b/src/registerInteractionListener.ts new file mode 100644 index 0000000..db3dd6a --- /dev/null +++ b/src/registerInteractionListener.ts @@ -0,0 +1,106 @@ +import { + ButtonInteraction, + Client, + CommandInteraction, + Message, + MessageActionRow, + SelectMenuInteraction, +} from 'discord.js'; +import { bindAll } from 'lodash'; +import { Command } from './Command'; +import { dispatch } from './dispatch'; +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 + * 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 + * @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, + messageFilters: MessageFilter[] +): void { + /** + * 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: 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) + ); + client.on( + 'messageUpdate', + async (_, message) => + await runMessageFilters(message as Message, messageFilters) + ); + + // handle incoming interactions + client.on('interactionCreate', (interaction) => { + interaction = bindAllMethods(interaction); + if (interaction instanceof CommandInteraction) { + dispatch(interaction, commands, registerUI, registerMessageFilters); + } else if (interaction instanceof ButtonInteraction) { + const handler = buttonListeners.get(interaction.customId); + if (!handler) { + console.log(`Unregistered customId "${interaction.customId}"`); + return; + } + handler(interaction); + } 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); + } + }); +} + +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; +} 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] }; diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index c9da49e..a296fc5 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'); @@ -99,9 +102,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; } @@ -134,8 +138,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) ); @@ -163,7 +171,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; @@ -188,8 +201,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; }