From 4e15a5e52b5cda3b1d20c6a622abce4f5f02baa7 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Fri, 24 Jun 2022 20:09:33 +1200 Subject: [PATCH 01/14] fix: load dotenv in env checks --- src/plugins/envCheck.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/envCheck.ts b/src/plugins/envCheck.ts index aeb7d49..1e3640d 100644 --- a/src/plugins/envCheck.ts +++ b/src/plugins/envCheck.ts @@ -111,7 +111,7 @@ declare module "fastify" { } // eslint-disable-next-line @typescript-eslint/require-await const envPlugin = fp(async (instance: FastifyInstance) => { - const envVars = envSchema({ schema: schemaForEnv }); + const envVars = envSchema({ schema: schemaForEnv, dotenv: true }); instance.decorate("envVars", envVars); }); From e4eaa57705653fd6283beffdf1f6b3cbf6ed0575 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Fri, 24 Jun 2022 20:12:29 +1200 Subject: [PATCH 02/14] feat: add embed to message logic Switch rest client to @discordjs/rest Support sending files in logging messages --- package-lock.json | 346 +++++++----------- package.json | 6 +- .../20220624062431_add_embeds/migration.sql | 38 ++ prisma/schema.prisma | 131 ++++--- src/errors.ts | 3 + src/index.ts | 2 +- src/interactions/commands/chatInput/config.ts | 2 +- src/lib/applicationCommands/registerHelper.ts | 22 +- src/lib/logging/manager.ts | 10 +- src/lib/messages/delete.ts | 56 ++- src/lib/messages/edit.ts | 107 +++++- src/lib/messages/embeds/parser.ts | 58 +++ src/lib/messages/embeds/types.ts | 18 + src/lib/messages/send.ts | 78 +++- src/lib/webhook/manager.ts | 43 ++- src/plugins/discord-rest.ts | 12 +- 16 files changed, 603 insertions(+), 329 deletions(-) create mode 100644 prisma/migrations/20220624062431_add_embeds/migration.sql create mode 100644 src/lib/messages/embeds/parser.ts create mode 100644 src/lib/messages/embeds/types.ts diff --git a/package-lock.json b/package-lock.json index 5c0ec7b..ab62538 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,11 @@ "version": "1.0.0a", "license": "AGPL-3.0-or-later", "dependencies": { - "@prisma/client": "^3.9.2", + "@discordjs/rest": "^0.5.0", + "@prisma/client": "^3.15.2", "@sentry/node": "^6.19.6", "@sinclair/typebox": "^0.20.5", "axios": "^0.26.0", - "detritus-client-rest": "^0.10.5", "discord-interactions": "^2.4.1", "env-schema": "^4.0.0", "fastify": "^3.22.0", @@ -50,7 +50,7 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-simple-import-sort": "^7.0.0", "prettier": "2.6.1", - "prisma": "^3.9.2", + "prisma": "^3.15.2", "tap": "^16.0.1", "ts-node": "^10.5.0", "tsc-watch": "^4.6.0", @@ -506,6 +506,40 @@ "kuler": "^2.0.0" } }, + "node_modules/@discordjs/collection": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.7.0.tgz", + "integrity": "sha512-R5i8Wb8kIcBAFEPLLf7LVBQKBDYUL+ekb23sOgpkpyGT+V4P7V83wTxcsqmX+PbqHt4cEHn053uMWfRqh/Z/nA==", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/rest": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-0.5.0.tgz", + "integrity": "sha512-S4E1YNz1UxgUfMPpMeqzPPkCfXE877zOsvKM5WEmwIhcpz1PQV7lzqlEOuz194UuwOJLLjQFBgQELnQfCX9UfA==", + "dependencies": { + "@discordjs/collection": "^0.7.0", + "@sapphire/async-queue": "^1.3.1", + "@sapphire/snowflake": "^3.2.2", + "discord-api-types": "^0.33.3", + "tslib": "^2.4.0", + "undici": "^5.4.0" + }, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/rest/node_modules/discord-api-types": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.33.5.tgz", + "integrity": "sha512-dvO5M52v7m7Dy96+XUnzXNsQ/0npsYpU6dL205kAtEDueswoz3aU3bh1UMoK4cQmcGtB1YRyLKqp+DXi05lzFg==" + }, + "node_modules/@discordjs/rest/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + }, "node_modules/@eslint/eslintrc": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz", @@ -688,12 +722,12 @@ } }, "node_modules/@prisma/client": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-3.9.2.tgz", - "integrity": "sha512-VlEIYVMyfFZHbVBOlunPl47gmP/Z0zzPjPj8I7uKEIaABqrUy50ru3XS0aZd8GFvevVwt7p91xxkUjNjrWhKAQ==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-3.15.2.tgz", + "integrity": "sha512-ErqtwhX12ubPhU4d++30uFY/rPcyvjk+mdifaZO5SeM21zS3t4jQrscy8+6IyB0GIYshl5ldTq6JSBo1d63i8w==", "hasInstallScript": true, "dependencies": { - "@prisma/engines-version": "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009" + "@prisma/engines-version": "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e" }, "engines": { "node": ">=12.6" @@ -723,16 +757,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/@prisma/engines": { - "version": "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz", - "integrity": "sha512-qM+uJbkelB21bnK44gYE049YTHIjHysOuj0mj5U2gDGyNLfmiazlggzFPCgEjgme4U5YB2tYs6Z5Hq08Kl8pjA==", + "version": "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e.tgz", + "integrity": "sha512-NHlojO1DFTsSi3FtEleL9QWXeSF/UjhCW0fgpi7bumnNZ4wj/eQ+BJJ5n2pgoOliTOGv9nX2qXvmHap7rJMNmg==", "devOptional": true, "hasInstallScript": true }, "node_modules/@prisma/engines-version": { - "version": "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz", - "integrity": "sha512-5Dh+qTDhpPR66w6NNAnPs+/W/Qt4r1DSd+qhfPFcDThUK4uxoZKGlPb2IYQn5LL+18aIGnmteDf7BnVMmvBNSQ==" + "version": "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e.tgz", + "integrity": "sha512-e3k2Vd606efd1ZYy2NQKkT4C/pn31nehyLhVug6To/q8JT8FpiMrDy7zmm3KLF0L98NOQQcutaVtAPhzKhzn9w==" }, "node_modules/@prisma/generator-helper": { "version": "3.9.2", @@ -745,6 +779,24 @@ "cross-spawn": "7.0.3" } }, + "node_modules/@sapphire/async-queue": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.3.1.tgz", + "integrity": "sha512-FFTlPOWZX1kDj9xCAsRzH5xEJfawg1lNoYAA+ecOWJMHOfiZYb1uXOI3ne9U4UILSEPwfE68p3T9wUHwIQfR0g==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.2.2.tgz", + "integrity": "sha512-ula2O0kpSZtX9rKXNeQMrHwNd7E4jPDJYUXmEGTFdMRfyfMw+FPyh04oKMjAiDuOi64bYgVkOV3MjK+loImFhQ==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@sentry/core": { "version": "6.19.6", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.6.tgz", @@ -1289,10 +1341,6 @@ "node": ">=10" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "license": "MIT" - }, "node_modules/atomic-sleep": { "version": "1.0.0", "license": "MIT", @@ -1590,16 +1638,6 @@ "text-hex": "1.0.x" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -1724,13 +1762,6 @@ "node": ">=8" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/denque": { "version": "1.5.1", "license": "Apache-2.0", @@ -1749,14 +1780,6 @@ "version": "1.0.4", "license": "MIT" }, - "node_modules/detritus-client-rest": { - "version": "0.10.5", - "license": "MIT", - "dependencies": { - "detritus-rest": "^0.7.0", - "detritus-utils": "^0.4.0" - } - }, "node_modules/detritus-client-socket": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/detritus-client-socket/-/detritus-client-socket-0.8.3.tgz", @@ -1800,33 +1823,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.14.tgz", "integrity": "sha512-HTgN9C1x8iMFycNAkLKIkJg+D3z1j0/Bm5/CMyzI3bO8a8dS0U1FxlS6hfvg5MPUYqkG3Y1myuje7HUNKLX9Mw==" }, - "node_modules/detritus-rest": { - "version": "0.7.0", - "license": "MIT", - "dependencies": { - "form-data": "^3.0.0", - "node-fetch": "^2.6.1" - } - }, - "node_modules/detritus-rest/node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/detritus-utils": { "version": "0.4.0", "license": "MIT", @@ -2642,19 +2638,6 @@ "node": ">=8.0.0" } }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/form-data-encoder": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz", @@ -3515,23 +3498,6 @@ "node": ">=4" } }, - "node_modules/mime-db": { - "version": "1.51.0", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.34", - "license": "MIT", - "dependencies": { - "mime-db": "1.51.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "3.0.4", "license": "ISC", @@ -3958,13 +3924,13 @@ } }, "node_modules/prisma": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-3.9.2.tgz", - "integrity": "sha512-i9eK6cexV74OgeWaH3+e6S07kvC9jEZTl6BqtBH398nlCU0tck7mE9dicY6YQd+euvMjjCtY89q4NgmaPnUsSg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-3.15.2.tgz", + "integrity": "sha512-nMNSMZvtwrvoEQ/mui8L/aiCLZRCj5t6L3yujKpcDhIPk7garp8tL4nMx2+oYsN0FWBacevJhazfXAbV1kfBzA==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/engines": "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009" + "@prisma/engines": "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e" }, "bin": { "prisma": "build/index.js", @@ -6668,11 +6634,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, "node_modules/triple-beam": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", @@ -6815,6 +6776,14 @@ "node": ">=4.2.0" } }, + "node_modules/undici": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.5.1.tgz", + "integrity": "sha512-MEvryPLf18HvlCbLSzCW0U00IMftKGI5udnjrQbC5D4P0Hodwffhv+iGfWuJwg16Y/TK11ZFK8i+BPVW2z/eAw==", + "engines": { + "node": ">=12.18" + } + }, "node_modules/unicode-length": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/unicode-length/-/unicode-length-2.0.2.tgz", @@ -6899,20 +6868,6 @@ "node": ">= 12" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "license": "ISC", @@ -7470,6 +7425,36 @@ "kuler": "^2.0.0" } }, + "@discordjs/collection": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.7.0.tgz", + "integrity": "sha512-R5i8Wb8kIcBAFEPLLf7LVBQKBDYUL+ekb23sOgpkpyGT+V4P7V83wTxcsqmX+PbqHt4cEHn053uMWfRqh/Z/nA==" + }, + "@discordjs/rest": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-0.5.0.tgz", + "integrity": "sha512-S4E1YNz1UxgUfMPpMeqzPPkCfXE877zOsvKM5WEmwIhcpz1PQV7lzqlEOuz194UuwOJLLjQFBgQELnQfCX9UfA==", + "requires": { + "@discordjs/collection": "^0.7.0", + "@sapphire/async-queue": "^1.3.1", + "@sapphire/snowflake": "^3.2.2", + "discord-api-types": "^0.33.3", + "tslib": "^2.4.0", + "undici": "^5.4.0" + }, + "dependencies": { + "discord-api-types": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.33.5.tgz", + "integrity": "sha512-dvO5M52v7m7Dy96+XUnzXNsQ/0npsYpU6dL205kAtEDueswoz3aU3bh1UMoK4cQmcGtB1YRyLKqp+DXi05lzFg==" + }, + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + } + } + }, "@eslint/eslintrc": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz", @@ -7616,11 +7601,11 @@ } }, "@prisma/client": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-3.9.2.tgz", - "integrity": "sha512-VlEIYVMyfFZHbVBOlunPl47gmP/Z0zzPjPj8I7uKEIaABqrUy50ru3XS0aZd8GFvevVwt7p91xxkUjNjrWhKAQ==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-3.15.2.tgz", + "integrity": "sha512-ErqtwhX12ubPhU4d++30uFY/rPcyvjk+mdifaZO5SeM21zS3t4jQrscy8+6IyB0GIYshl5ldTq6JSBo1d63i8w==", "requires": { - "@prisma/engines-version": "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009" + "@prisma/engines-version": "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e" } }, "@prisma/debug": { @@ -7641,15 +7626,15 @@ } }, "@prisma/engines": { - "version": "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz", - "integrity": "sha512-qM+uJbkelB21bnK44gYE049YTHIjHysOuj0mj5U2gDGyNLfmiazlggzFPCgEjgme4U5YB2tYs6Z5Hq08Kl8pjA==", + "version": "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e.tgz", + "integrity": "sha512-NHlojO1DFTsSi3FtEleL9QWXeSF/UjhCW0fgpi7bumnNZ4wj/eQ+BJJ5n2pgoOliTOGv9nX2qXvmHap7rJMNmg==", "devOptional": true }, "@prisma/engines-version": { - "version": "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz", - "integrity": "sha512-5Dh+qTDhpPR66w6NNAnPs+/W/Qt4r1DSd+qhfPFcDThUK4uxoZKGlPb2IYQn5LL+18aIGnmteDf7BnVMmvBNSQ==" + "version": "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e.tgz", + "integrity": "sha512-e3k2Vd606efd1ZYy2NQKkT4C/pn31nehyLhVug6To/q8JT8FpiMrDy7zmm3KLF0L98NOQQcutaVtAPhzKhzn9w==" }, "@prisma/generator-helper": { "version": "3.9.2", @@ -7662,6 +7647,16 @@ "cross-spawn": "7.0.3" } }, + "@sapphire/async-queue": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.3.1.tgz", + "integrity": "sha512-FFTlPOWZX1kDj9xCAsRzH5xEJfawg1lNoYAA+ecOWJMHOfiZYb1uXOI3ne9U4UILSEPwfE68p3T9wUHwIQfR0g==" + }, + "@sapphire/snowflake": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.2.2.tgz", + "integrity": "sha512-ula2O0kpSZtX9rKXNeQMrHwNd7E4jPDJYUXmEGTFdMRfyfMw+FPyh04oKMjAiDuOi64bYgVkOV3MjK+loImFhQ==" + }, "@sentry/core": { "version": "6.19.6", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.6.tgz", @@ -8046,9 +8041,6 @@ "integrity": "sha512-14LjCmlK1PK8eDtTezR6WX8TMaYNIzBIsd2D1sGoGjgx0BuNMMoSdk7i/drlbtamy0AWv9yv2tkB+ASdmeqFIw==", "dev": true }, - "asynckit": { - "version": "0.4.0" - }, "atomic-sleep": { "version": "1.0.0" }, @@ -8258,12 +8250,6 @@ "text-hex": "1.0.x" } }, - "combined-stream": { - "version": "1.0.8", - "requires": { - "delayed-stream": "~1.0.0" - } - }, "commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -8351,9 +8337,6 @@ "strip-bom": "^4.0.0" } }, - "delayed-stream": { - "version": "1.0.0" - }, "denque": { "version": "1.5.1" }, @@ -8363,13 +8346,6 @@ "destroy": { "version": "1.0.4" }, - "detritus-client-rest": { - "version": "0.10.5", - "requires": { - "detritus-rest": "^0.7.0", - "detritus-utils": "^0.4.0" - } - }, "detritus-client-socket": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/detritus-client-socket/-/detritus-client-socket-0.8.3.tgz", @@ -8387,23 +8363,6 @@ } } }, - "detritus-rest": { - "version": "0.7.0", - "requires": { - "form-data": "^3.0.0", - "node-fetch": "^2.6.1" - }, - "dependencies": { - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - } - } - }, "detritus-utils": { "version": "0.4.0", "requires": {} @@ -9020,16 +8979,6 @@ "signal-exit": "^3.0.2" } }, - "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, "form-data-encoder": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz", @@ -9652,15 +9601,6 @@ "mime": { "version": "1.6.0" }, - "mime-db": { - "version": "1.51.0" - }, - "mime-types": { - "version": "2.1.34", - "requires": { - "mime-db": "1.51.0" - } - }, "minimatch": { "version": "3.0.4", "requires": { @@ -9960,12 +9900,12 @@ "dev": true }, "prisma": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-3.9.2.tgz", - "integrity": "sha512-i9eK6cexV74OgeWaH3+e6S07kvC9jEZTl6BqtBH398nlCU0tck7mE9dicY6YQd+euvMjjCtY89q4NgmaPnUsSg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-3.15.2.tgz", + "integrity": "sha512-nMNSMZvtwrvoEQ/mui8L/aiCLZRCj5t6L3yujKpcDhIPk7garp8tL4nMx2+oYsN0FWBacevJhazfXAbV1kfBzA==", "devOptional": true, "requires": { - "@prisma/engines": "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009" + "@prisma/engines": "3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e" } }, "prisma-field-encryption": { @@ -11739,11 +11679,6 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, "triple-beam": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", @@ -11836,6 +11771,11 @@ "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", "dev": true }, + "undici": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.5.1.tgz", + "integrity": "sha512-MEvryPLf18HvlCbLSzCW0U00IMftKGI5udnjrQbC5D4P0Hodwffhv+iGfWuJwg16Y/TK11ZFK8i+BPVW2z/eAw==" + }, "unicode-length": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/unicode-length/-/unicode-length-2.0.2.tgz", @@ -11900,20 +11840,6 @@ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.1.tgz", "integrity": "sha512-3ux37gEX670UUphBF9AMCq8XM6iQ8Ac6A+DSRRjDoRBm1ufCkaCDdNVbaqq60PsEkdNlLKrGtv/YBP4EJXqNtQ==" }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "which": { "version": "2.0.2", "requires": { diff --git a/package.json b/package.json index c330577..1f8999b 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,11 @@ "format:check": "prettier --check ." }, "dependencies": { - "@prisma/client": "^3.9.2", + "@discordjs/rest": "^0.5.0", + "@prisma/client": "^3.15.2", "@sentry/node": "^6.19.6", "@sinclair/typebox": "^0.20.5", "axios": "^0.26.0", - "detritus-client-rest": "^0.10.5", "discord-interactions": "^2.4.1", "env-schema": "^4.0.0", "fastify": "^3.22.0", @@ -60,7 +60,7 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-simple-import-sort": "^7.0.0", "prettier": "2.6.1", - "prisma": "^3.9.2", + "prisma": "^3.15.2", "tap": "^16.0.1", "ts-node": "^10.5.0", "tsc-watch": "^4.6.0", diff --git a/prisma/migrations/20220624062431_add_embeds/migration.sql b/prisma/migrations/20220624062431_add_embeds/migration.sql new file mode 100644 index 0000000..a27c987 --- /dev/null +++ b/prisma/migrations/20220624062431_add_embeds/migration.sql @@ -0,0 +1,38 @@ +-- AlterTable +ALTER TABLE "Message" ADD COLUMN "internalId" SERIAL NOT NULL, +ADD CONSTRAINT "Message_pkey" PRIMARY KEY ("internalId"); + +-- CreateTable +CREATE TABLE "MessageEmbed" ( + "id" SERIAL NOT NULL, + "title" TEXT, + "description" TEXT, + "url" TEXT, + "authorName" TEXT, + "footerText" TEXT, + "timestamp" TIMESTAMP(3), + "color" INTEGER, + "messageId" INTEGER NOT NULL, + + CONSTRAINT "MessageEmbed_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmbedField" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "value" TEXT NOT NULL, + "inline" BOOLEAN NOT NULL DEFAULT false, + "embedId" INTEGER NOT NULL, + + CONSTRAINT "EmbedField_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "MessageEmbed_messageId_key" ON "MessageEmbed"("messageId"); + +-- AddForeignKey +ALTER TABLE "MessageEmbed" ADD CONSTRAINT "MessageEmbed_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("internalId") ON DELETE CASCADE ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "EmbedField" ADD CONSTRAINT "EmbedField_embedId_fkey" FOREIGN KEY ("embedId") REFERENCES "MessageEmbed"("id") ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f7d0c78..946e540 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,54 +7,75 @@ datasource db { url = env("DATABASE_URL") } - model Channel { - id BigInt @id - permissions Json? - webhookId BigInt? - webhookToken String? @db.VarChar(255) /// @encrypted - guildId BigInt - guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade, onUpdate: NoAction) - messages Message[] - -} + id BigInt @id + permissions Json? + webhookId BigInt? + webhookToken String? @db.VarChar(255) /// @encrypted + guildId BigInt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade, onUpdate: NoAction) + messages Message[] +} model Guild { - id BigInt @id - logChannelId BigInt? - permissions Json? - beforeMigration Boolean @default(false) // This indicates if the guild existed before the migration to storing message content. + id BigInt @id + logChannelId BigInt? + permissions Json? + beforeMigration Boolean @default(false) // This indicates if the guild existed before the migration to storing message content. // If it was it gains access to adding any previous bot sent message to the database. - messages Message[] - channels Channel[] + messages Message[] + channels Channel[] } model Message { - id BigInt - guildId BigInt - guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade, onUpdate: NoAction) - channelId BigInt - channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade, onUpdate: NoAction) - content String /// @encrypted - editedAt DateTime - editedBy BigInt - deleted Boolean @default(false) - addedByUser Boolean @default(false) // This indicates if the message was a message previously sent by the bot, and then added to the database. + internalId Int @id @default(autoincrement()) // Exists to allow for storage of message history and ease of relating + id BigInt + guildId BigInt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade, onUpdate: NoAction) + channelId BigInt + channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade, onUpdate: NoAction) + content String /// @encrypted + editedAt DateTime + editedBy BigInt + deleted Boolean @default(false) + addedByUser Boolean @default(false) // This indicates if the message was a message previously sent by the bot, and then added to the database. - @@unique([id, editedAt]) + embed MessageEmbed? -} + @@unique([id, editedAt]) +} +model MessageEmbed { + id Int @id @default(autoincrement()) + title String? /// @encrypted + description String? /// @encrypted + url String? /// @encrypted + authorName String? /// @encrypted + footerText String? /// @encrypted + timestamp DateTime? + color Int? + fields EmbedField[] + messageId Int @unique + message Message @relation(fields: [messageId], references: [internalId], onDelete: Cascade, onUpdate: NoAction) +} +model EmbedField { + id Int @id @default(autoincrement()) + name String /// @encrypted + value String /// @encrypted + inline Boolean @default(false) + embedId Int + embed MessageEmbed @relation(fields: [embedId], references: [id], onDelete: Cascade, onUpdate: NoAction) +} model User { - id BigInt @id - oauthToken String? /// @encrypted + id BigInt @id + oauthToken String? /// @encrypted oauthTokenExpiration DateTime? - refreshToken String? /// @encrypted - staff Boolean @default(false) + refreshToken String? /// @encrypted + staff Boolean @default(false) } enum ReportStatus { @@ -67,35 +88,35 @@ enum ReportStatus { } model Report { - id BigInt @id @default(autoincrement()) - userId BigInt - content String /// @encrypted - messageId BigInt - guildId BigInt - channelId BigInt - reportedAt DateTime - resolvedAt DateTime? // Report should be resolved closed if this is set - status ReportStatus @default(Pending) - userReportReason String /// @encrypted + id BigInt @id @default(autoincrement()) + userId BigInt + content String /// @encrypted + messageId BigInt + guildId BigInt + channelId BigInt + reportedAt DateTime + resolvedAt DateTime? // Report should be resolved closed if this is set + status ReportStatus @default(Pending) + userReportReason String /// @encrypted staffResolvedReasonId BigInt? - staffResolvedReason ReportReason? @relation(fields: [staffResolvedReasonId], references: [id], onDelete: Cascade, onUpdate: NoAction) - messages ReportMessage[] -} + staffResolvedReason ReportReason? @relation(fields: [staffResolvedReasonId], references: [id], onDelete: Cascade, onUpdate: NoAction) + messages ReportMessage[] +} -model ReportReason { +model ReportReason { // Set by staff when closing the ticket - id BigInt @id @default(autoincrement()) - name String + id BigInt @id @default(autoincrement()) + name String description String - reports Report[] + reports Report[] } model ReportMessage { // Communication between the user and staff - doesn't include the inital report - id BigInt @id @default(autoincrement()) - authorId BigInt + id BigInt @id @default(autoincrement()) + authorId BigInt fromStaff Boolean // If the author of the message was staff - content String /// @encrypted - reportId BigInt - report Report @relation(fields: [reportId], references: [id], onDelete: Cascade, onUpdate: NoAction) -} \ No newline at end of file + content String /// @encrypted + reportId BigInt + report Report @relation(fields: [reportId], references: [id], onDelete: Cascade, onUpdate: NoAction) +} diff --git a/src/errors.ts b/src/errors.ts index f2a39ed..088d514 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -28,6 +28,7 @@ enum InteractionOrRequestFinalStatus { TAG_NOT_FOUND, NO_PERMISSIONS_PRESET_SELECTED, MANAGEMENT_PERMISSIONS_CANNOT_BE_SET_ON_CHANNEL_LEVEL, + MESSAGE_HAS_NO_CONTENT, GENERIC_EXPECTED_PERMISSIONS_FAILURE = 3000, USER_MISSING_DISCORD_PERMISSION, BOT_MISSING_DISCORD_PERMISSION, @@ -62,6 +63,8 @@ enum InteractionOrRequestFinalStatus { CREATE_WEBHOOK_RESULT_MISSING_TOKEN, ROLE_NOT_IN_CACHE, PERMISSIONS_CANNOT_CROSSOVER_WHEN_UPDATING, + TOO_MANY_EMBEDS, + MESSAGE_NOT_FOUND_IN_DATABASE_AFTER_CHECKS_DONE, } class CustomError extends Error { diff --git a/src/index.ts b/src/index.ts index 0062af5..48e277d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,7 @@ instance.setErrorHandler(async (error, request, reply) => { // These are plugins that are separate from versioning await instance.register(prismaPlugin); await instance.register(discordRestPlugin, { - detritus: { token: instance.envVars.DISCORD_TOKEN }, + discord: { token: instance.envVars.DISCORD_TOKEN }, }); await instance.register(redisRestPlugin, { redis: { diff --git a/src/interactions/commands/chatInput/config.ts b/src/interactions/commands/chatInput/config.ts index fb8ec57..fc59405 100644 --- a/src/interactions/commands/chatInput/config.ts +++ b/src/interactions/commands/chatInput/config.ts @@ -659,7 +659,7 @@ async function handleLoggingChannelRemoveSubcommand({ { embeds: [logEmbed], username: "Message Manager Logging", - avatarUrl: instance.envVars.AVATAR_URL, + avatar_url: instance.envVars.AVATAR_URL, } ); } diff --git a/src/lib/applicationCommands/registerHelper.ts b/src/lib/applicationCommands/registerHelper.ts index 463e545..f3a5722 100644 --- a/src/lib/applicationCommands/registerHelper.ts +++ b/src/lib/applicationCommands/registerHelper.ts @@ -1,5 +1,8 @@ import { Snowflake } from "discord-api-types/globals"; -import { RESTGetAPIApplicationGuildCommandsResult } from "discord-api-types/v9"; +import { + RESTGetAPIApplicationGuildCommandsResult, + Routes, +} from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; import command from "../../discord_commands/guildAddMessage.json" assert { type: "json" }; @@ -7,9 +10,8 @@ async function registerAddCommand( guildId: Snowflake, instance: FastifyInstance ) { - const commands = (await instance.restClient.fetchApplicationGuildCommands( - instance.envVars.DISCORD_CLIENT_ID, - guildId + const commands = (await instance.restClient.get( + Routes.applicationGuildCommands(instance.envVars.DISCORD_CLIENT_ID, guildId) )) as RESTGetAPIApplicationGuildCommandsResult; if ( commands.find( @@ -19,12 +21,12 @@ async function registerAddCommand( return; // The command is already registered } - await instance.restClient.createApplicationGuildCommand( - instance.envVars.DISCORD_CLIENT_ID, - guildId, - - // @ts-expect-error The client does not accept / is not updated to include message commands. However it should still work - command + await instance.restClient.post( + Routes.applicationGuildCommands( + instance.envVars.DISCORD_CLIENT_ID, + guildId + ), + { body: command } ); } export { registerAddCommand }; diff --git a/src/lib/logging/manager.ts b/src/lib/logging/manager.ts index 7aa95c8..4e824ab 100644 --- a/src/lib/logging/manager.ts +++ b/src/lib/logging/manager.ts @@ -1,3 +1,4 @@ +import { RawFile } from "@discordjs/rest"; import { Snowflake } from "discord-api-types/globals"; import { APIEmbed, APIMessage } from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; @@ -82,12 +83,14 @@ export default class LoggingManager { ignoreErrors, message, embeds, + files, }: { guildId: Snowflake; ignoreErrors?: boolean; message?: string; embeds: APIEmbed[] | undefined; + files?: RawFile[]; }): Promise { // Logs can be "passed" if the channel isn't set, or if the webhook doesn't exist and the bot cannot create a new webhook // This means that this function can be called without checking if the log channel is set @@ -104,7 +107,12 @@ export default class LoggingManager { }; try { - return this._webhookManager.sendWebhookMessage(channelId, guildId, data); + return this._webhookManager.sendWebhookMessage( + channelId, + guildId, + data, + files + ); // eslint-disable-next-line no-empty } catch (error) { if (!(ignoreErrors ?? false)) { diff --git a/src/lib/messages/delete.ts b/src/lib/messages/delete.ts index ff18441..d19d0bd 100644 --- a/src/lib/messages/delete.ts +++ b/src/lib/messages/delete.ts @@ -1,6 +1,6 @@ +import { DiscordAPIError, RawFile } from "@discordjs/rest"; import { Message } from "@prisma/client"; -import { DiscordHTTPError } from "detritus-client-rest/lib/errors"; -import { APIEmbed, Snowflake } from "discord-api-types/v9"; +import { APIEmbed, Routes, Snowflake } from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; import { embedPink } from "../../constants"; @@ -14,6 +14,7 @@ import { InternalPermissions } from "../permissions/consts"; import { GuildSession } from "../session"; import { checkDatabaseMessage } from "./checks"; import { requiredPermissionsDelete } from "./consts"; +import { StoredEmbed } from "./embeds/types"; import { missingBotDiscordPermissionMessage, missingUserDiscordPermissionMessage, @@ -98,12 +99,27 @@ async function deleteMessage({ }: DeleteOptions) { await checkDeletePossible({ channelId, instance, messageId, session }); try { - await instance.restClient.deleteMessage(channelId, messageId); - const messageBefore = (await instance.prisma.message.findFirst({ + await instance.restClient.delete( + Routes.channelMessage(channelId, messageId) + ); + const messageBefore = await instance.prisma.message.findFirst({ where: { id: BigInt(messageId) }, orderBy: { editedAt: "desc" }, - })) as Message; + include: { + embed: { + include: { + fields: true, + }, + }, + }, + }); + if (!messageBefore) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.MESSAGE_NOT_FOUND_IN_DATABASE_AFTER_CHECKS_DONE, + "MESSAGE_NOT_FOUND_IN_DATABASE_AFTER_CHECKS_DONE" + ); + } // this is also a create, as messages will form a message history await instance.prisma.message.create({ data: { @@ -138,7 +154,7 @@ async function deleteMessage({ }, }, }); - const embed: APIEmbed = { + const logEmbed: APIEmbed = { color: embedPink, title: "Message Deleted", description: @@ -150,13 +166,37 @@ async function deleteMessage({ ], timestamp: new Date().toISOString(), }; + let embedBefore: StoredEmbed | undefined = undefined; + if (messageBefore?.embed !== null && messageBefore?.embed !== undefined) { + embedBefore = { + title: messageBefore.embed.title ?? undefined, + description: messageBefore.embed.description ?? undefined, + url: messageBefore.embed.url ?? undefined, + timestamp: messageBefore.embed.timestamp?.toISOString() ?? undefined, + color: messageBefore.embed.color ?? undefined, + footerText: messageBefore.embed.footerText ?? undefined, + authorName: messageBefore.embed.authorName ?? undefined, + fields: messageBefore.embed.fields ?? undefined, + }; + } + + const files: RawFile[] = []; + if (embedBefore !== undefined) { + files.push({ + name: "embed.json", + data: JSON.stringify(embedBefore), + }); + logEmbed.description += + "\nEmbed representation can be found in the attachment."; + } // Send log message await instance.loggingManager.sendLogMessage({ guildId: session.guildId, - embeds: [embed], + embeds: [logEmbed], + files, }); } catch (error) { - if (error instanceof DiscordHTTPError) { + if (error instanceof DiscordAPIError) { if (error.code === 404) { throw new UnexpectedFailure( InteractionOrRequestFinalStatus.CHANNEL_NOT_FOUND_DISCORD_HTTP, diff --git a/src/lib/messages/edit.ts b/src/lib/messages/edit.ts index 33ea331..2af5bfd 100644 --- a/src/lib/messages/edit.ts +++ b/src/lib/messages/edit.ts @@ -1,6 +1,6 @@ -import { Message } from "@prisma/client"; -import { DiscordHTTPError } from "detritus-client-rest/lib/errors"; -import { APIEmbed, Snowflake } from "discord-api-types/v9"; +import { DiscordAPIError, RawFile } from "@discordjs/rest"; +import { Message, Prisma } from "@prisma/client"; +import { APIEmbed, Routes, Snowflake } from "discord-api-types/v9"; import { RESTPatchAPIChannelMessageResult } from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; @@ -16,6 +16,11 @@ import { InternalPermissions } from "../permissions/consts"; import { GuildSession } from "../session"; import { checkDatabaseMessage } from "./checks"; import { requiredPermissionsEdit } from "./consts"; +import { + createSendableEmbedFromStoredEmbed, + createStoredEmbedFromAPIMessage, +} from "./embeds/parser"; +import { StoredEmbed } from "./embeds/types"; import { missingBotDiscordPermissionMessage, missingUserDiscordPermissionMessage, @@ -97,6 +102,7 @@ const checkEditPossible = async ({ interface EditMessageOptions extends CheckEditPossibleOptions { content: string; + embed?: StoredEmbed; } async function editMessage({ @@ -105,20 +111,67 @@ async function editMessage({ instance, messageId, session, + embed, }: EditMessageOptions) { await checkEditPossible({ channelId, instance, messageId, session }); try { - const response = (await instance.restClient.editMessage( - channelId, - messageId, + const embeds: APIEmbed[] = []; + if (embed) { + embeds.push(createSendableEmbedFromStoredEmbed(embed)); + } + const response = (await instance.restClient.patch( + Routes.channelMessage(channelId, messageId), { - content: content, + body: { content: content, embeds }, } )) as RESTPatchAPIChannelMessageResult; const messageBefore = await instance.prisma.message.findFirst({ where: { id: BigInt(messageId) }, orderBy: { editedAt: "desc" }, + include: { + embed: { + include: { + fields: true, + }, + }, + }, }); + const sentEmbed = createStoredEmbedFromAPIMessage(response); + let embedQuery: + | Prisma.MessageEmbedCreateNestedOneWithoutMessageInput + | undefined = undefined; + if (sentEmbed !== null) { + let fieldQuery: + | Prisma.EmbedFieldCreateNestedManyWithoutEmbedInput + | undefined; + + if (sentEmbed.fields && sentEmbed.fields.length > 0) { + fieldQuery = { + create: sentEmbed.fields.map((field) => ({ + name: field.name, + value: field.value, + inline: field.inline, + })), + }; + } + let timestamp: Date | undefined; + if (sentEmbed.timestamp !== undefined) { + timestamp = new Date(sentEmbed.timestamp); + } + + embedQuery = { + create: { + title: sentEmbed.title, + description: sentEmbed.description, + url: sentEmbed.url, + timestamp, + color: sentEmbed.color, + footerText: sentEmbed.footerText, + authorName: sentEmbed.authorName, + fields: fieldQuery, + }, + }; + } // Since message will contain message history too await instance.prisma.message.create({ @@ -152,6 +205,7 @@ async function editMessage({ }, }, }, + embed: embedQuery, }, }); // Check if message history count hasn't exceeded the limit, if it has then delete the oldest history @@ -181,7 +235,7 @@ async function editMessage({ }); } } - const embed: APIEmbed = { + const logEmbed: APIEmbed = { color: embedPink, title: "Message Edited", description: @@ -196,13 +250,46 @@ async function editMessage({ ], timestamp: new Date().toISOString(), }; + let embedBefore: StoredEmbed | undefined = undefined; + if (messageBefore?.embed !== null && messageBefore?.embed !== undefined) { + embedBefore = { + title: messageBefore.embed.title ?? undefined, + description: messageBefore.embed.description ?? undefined, + url: messageBefore.embed.url ?? undefined, + timestamp: messageBefore.embed.timestamp?.toISOString() ?? undefined, + color: messageBefore.embed.color ?? undefined, + footerText: messageBefore.embed.footerText ?? undefined, + authorName: messageBefore.embed.authorName ?? undefined, + fields: messageBefore.embed.fields ?? undefined, + }; + } + + const files: RawFile[] = []; + if ( + sentEmbed !== null && + sentEmbed !== undefined && + embedBefore !== undefined + ) { + files.push({ + name: "embed-before.json", + data: JSON.stringify(embedBefore), + }); + files.push({ + name: "embed-after.json", + data: JSON.stringify(sentEmbed), + }); + logEmbed.description += + "\nEmbed representation can be found in the attachment."; + } + // Send log message await instance.loggingManager.sendLogMessage({ guildId: session.guildId, - embeds: [embed], + embeds: [logEmbed], + files, }); } catch (error) { - if (error instanceof DiscordHTTPError) { + if (error instanceof DiscordAPIError) { if (error.code === 404) { throw new UnexpectedFailure( InteractionOrRequestFinalStatus.CHANNEL_NOT_FOUND_DISCORD_HTTP, diff --git a/src/lib/messages/embeds/parser.ts b/src/lib/messages/embeds/parser.ts new file mode 100644 index 0000000..4c5c082 --- /dev/null +++ b/src/lib/messages/embeds/parser.ts @@ -0,0 +1,58 @@ +import { APIEmbed, APIMessage } from "discord-api-types/v9"; + +import { + InteractionOrRequestFinalStatus, + UnexpectedFailure, +} from "../../../errors"; +import { StoredEmbed } from "./types"; + +const createStoredEmbedFromAPIMessage = ( + message: APIMessage +): StoredEmbed | null => { + const embed = message.embeds[0]; + if (embed === undefined) { + return null; + } + if (message.embeds.length > 1) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.TOO_MANY_EMBEDS, + "Only one embed is expected on that message." + ); + } + return { + title: embed.title, + description: embed.description, + url: embed.url, + timestamp: embed.timestamp, + color: embed.color, + footerText: embed.footer?.text, + authorName: embed.author?.name, + fields: embed.fields, + }; +}; + +const createSendableEmbedFromStoredEmbed = (embed: StoredEmbed): APIEmbed => { + const sendableEmbed: APIEmbed = { + title: embed.title, + description: embed.description, + url: embed.url, + timestamp: embed.timestamp, + color: embed.color, + }; + if (embed.footerText !== undefined) { + sendableEmbed.footer = { + text: embed.footerText, + }; + } + if (embed.authorName !== undefined) { + sendableEmbed.author = { + name: embed.authorName, + }; + } + if (embed.fields !== undefined) { + sendableEmbed.fields = embed.fields; + } + return sendableEmbed; +}; + +export { createSendableEmbedFromStoredEmbed,createStoredEmbedFromAPIMessage }; diff --git a/src/lib/messages/embeds/types.ts b/src/lib/messages/embeds/types.ts new file mode 100644 index 0000000..d860bd1 --- /dev/null +++ b/src/lib/messages/embeds/types.ts @@ -0,0 +1,18 @@ +interface StoredField { + name: string; // Max 256 characters + value: string; // Max 1024 characters + inline?: boolean; +} + +interface StoredEmbed { + title?: string; // Max 256 characters + description?: string; // Max 4096 characters + url?: string; + timestamp?: string; + color?: number; + footerText?: string; + authorName?: string; + fields?: StoredField[]; // Max 25 +} + +export { StoredEmbed }; diff --git a/src/lib/messages/send.ts b/src/lib/messages/send.ts index 9266eb6..84ba655 100644 --- a/src/lib/messages/send.ts +++ b/src/lib/messages/send.ts @@ -1,9 +1,11 @@ -import { DiscordHTTPError } from "detritus-client-rest/lib/errors"; +import { DiscordAPIError, RawFile } from "@discordjs/rest"; +import { Prisma } from "@prisma/client"; import { APIEmbed, APIMessage, ChannelType, RESTPostAPIChannelMessageResult, + Routes, } from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; @@ -21,6 +23,11 @@ import { requiredPermissionsSendBotThread, requiredPermissionsSendUser, } from "./consts"; +import { + createSendableEmbedFromStoredEmbed, + createStoredEmbedFromAPIMessage, +} from "./embeds/parser"; +import { StoredEmbed } from "./embeds/types"; import { missingBotDiscordPermissionMessage, missingUserDiscordPermissionMessage, @@ -48,6 +55,7 @@ interface CheckSendMessageOptions { interface SendMessageOptions extends CheckSendMessageOptions { content: string; + embed?: StoredEmbed; } async function checkSendMessagePossible({ @@ -110,6 +118,7 @@ async function checkSendMessagePossible({ async function sendMessage({ content, + embed, channelId, instance, @@ -125,10 +134,54 @@ async function sendMessage({ thread, session, }); + const embeds: APIEmbed[] = []; + if (embed) { + embeds.push(createSendableEmbedFromStoredEmbed(embed)); + } try { - const messageResult = (await instance.restClient.createMessage(channelId, { - content, - })) as RESTPostAPIChannelMessageResult; + const messageResult = (await instance.restClient.post( + Routes.channelMessages(channelId), + { + body: { content, embeds }, + } + )) as RESTPostAPIChannelMessageResult; + const sentEmbed = createStoredEmbedFromAPIMessage(messageResult); + let embedQuery: + | Prisma.MessageEmbedCreateNestedOneWithoutMessageInput + | undefined = undefined; + if (sentEmbed !== null) { + let fieldQuery: + | Prisma.EmbedFieldCreateNestedManyWithoutEmbedInput + | undefined; + + if (sentEmbed.fields && sentEmbed.fields.length > 0) { + fieldQuery = { + create: sentEmbed.fields.map((field) => ({ + name: field.name, + value: field.value, + inline: field.inline, + })), + }; + } + let timestamp: Date | undefined; + if (sentEmbed.timestamp !== undefined) { + timestamp = new Date(sentEmbed.timestamp); + } + + embedQuery = { + create: { + title: sentEmbed.title, + description: sentEmbed.description, + url: sentEmbed.url, + timestamp, + color: sentEmbed.color, + footerText: sentEmbed.footerText, + authorName: sentEmbed.authorName, + fields: fieldQuery, + }, + }; + } + await instance.prisma.message.create({ data: { id: BigInt(messageResult.id), @@ -159,9 +212,10 @@ async function sendMessage({ }, }, }, + embed: embedQuery, }, }); - const embed: APIEmbed = { + const logEmbed: APIEmbed = { color: embedPink, title: "Message Sent", description: @@ -173,14 +227,24 @@ async function sendMessage({ ], timestamp: new Date().toISOString(), }; + const files: RawFile[] = []; + if (sentEmbed !== null) { + files.push({ + name: "embed.json", + data: JSON.stringify(sentEmbed), + }); + logEmbed.description += + "\nEmbed representation can be found in the attachment."; + } // Send log message await instance.loggingManager.sendLogMessage({ guildId: session.guildId, - embeds: [embed], + embeds: [logEmbed], + files, }); return messageResult; } catch (error) { - if (error instanceof DiscordHTTPError) { + if (error instanceof DiscordAPIError) { if (error.code === 404) { throw new UnexpectedFailure( InteractionOrRequestFinalStatus.CHANNEL_NOT_FOUND_DISCORD_HTTP, diff --git a/src/lib/webhook/manager.ts b/src/lib/webhook/manager.ts index 54e2f49..5b30891 100644 --- a/src/lib/webhook/manager.ts +++ b/src/lib/webhook/manager.ts @@ -1,11 +1,12 @@ -import { RequestTypes } from "detritus-client-rest"; -import { DiscordHTTPError } from "detritus-client-rest/lib/errors"; +import { DiscordAPIError, RawFile } from "@discordjs/rest"; import { Snowflake } from "discord-api-types/globals"; import { APIMessage, RESTGetAPIChannelWebhooksResult, RESTPostAPIChannelWebhookResult, + RESTPostAPIWebhookWithTokenJSONBody, RESTPostAPIWebhookWithTokenWaitResult, + Routes, } from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; @@ -60,11 +61,11 @@ export default class WebhookManager { ): Promise { let webhooks: RESTGetAPIChannelWebhooksResult; try { - webhooks = (await this._instance.restClient.fetchChannelWebhooks( - channelId + webhooks = (await this._instance.restClient.get( + Routes.channelWebhooks(channelId) )) as RESTGetAPIChannelWebhooksResult; } catch (error) { - if (error instanceof DiscordHTTPError) { + if (error instanceof DiscordAPIError) { if (error.code === 403 || error.code === 50013) { throw new ExpectedFailure( InteractionOrRequestFinalStatus.BOT_MISSING_DISCORD_PERMISSION, @@ -127,11 +128,16 @@ export default class WebhookManager { ): Promise { let webhook: RESTPostAPIChannelWebhookResult; try { - webhook = (await this._instance.restClient.createWebhook(channelId, { - name: "Message Manager Logging", - })) as RESTPostAPIChannelWebhookResult; + webhook = (await this._instance.restClient.post( + Routes.channelWebhooks(channelId), + { + body: { + name: "Message Manager Logging", + }, + } + )) as RESTPostAPIChannelWebhookResult; } catch (error) { - if (error instanceof DiscordHTTPError) { + if (error instanceof DiscordAPIError) { if (error.code === 403 || error.code === 50013) { throw new UnexpectedFailure( InteractionOrRequestFinalStatus.BOT_MISSING_DISCORD_PERMISSION, @@ -183,17 +189,18 @@ export default class WebhookManager { public async sendWebhookMessage( channelId: Snowflake, guildId: Snowflake, - data: RequestTypes.ExecuteWebhook + data: RESTPostAPIWebhookWithTokenJSONBody, + files?: RawFile[] ): Promise { const webhook = await this.getWebhook(channelId, guildId); - if (data.wait ?? false) { - // Always wait for the message to send - data.wait = true; - } - const message = (await this._instance.restClient.executeWebhook( - webhook.id, - webhook.token, - data + + const message = (await this._instance.restClient.post( + Routes.webhook(webhook.id, webhook.token), + { + body: data, + files, + query: new URLSearchParams({ wait: "true" }), + } )) as RESTPostAPIWebhookWithTokenWaitResult; return message; } diff --git a/src/plugins/discord-rest.ts b/src/plugins/discord-rest.ts index 5fc37dd..383fced 100644 --- a/src/plugins/discord-rest.ts +++ b/src/plugins/discord-rest.ts @@ -1,4 +1,4 @@ -import { Client } from "detritus-client-rest"; +import { REST } from "@discordjs/rest"; import { FastifyInstance, FastifyPluginOptions } from "fastify"; import fp from "fastify-plugin"; @@ -6,13 +6,13 @@ import DiscordOauthRequests from "../discordOauth"; declare module "fastify" { interface FastifyInstance { - restClient: Client; + restClient: REST; discordOauthRequests: DiscordOauthRequests; } } interface RestPluginOptions extends FastifyPluginOptions { - detritus?: { + discord?: { token?: string; }; } @@ -20,10 +20,12 @@ interface RestPluginOptions extends FastifyPluginOptions { const discordRestPlugin = fp( // eslint-disable-next-line @typescript-eslint/require-await async (server: FastifyInstance, options?: RestPluginOptions) => { - if (options?.detritus?.token === undefined) { + if (options?.discord?.token === undefined) { throw new Error("Token not set"); } - const restClient = new Client(options.detritus.token); + const restClient = new REST({ + version: "9", + }).setToken(options.discord.token); server.decorate("restClient", restClient); const discordOauthRequests = new DiscordOauthRequests(server); From 91e2b8d82dc109f438f8ecbcbf23b567a9818b6d Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Mon, 27 Jun 2022 12:58:18 +1200 Subject: [PATCH 03/14] feat: Add embed message "generation" Add message generation flow Add quick sending method Update embed representation to allow all settable fields --- .../migration.sql | 5 + prisma/schema.prisma | 26 +- src/discord_commands/global.json | 10 +- src/errors.ts | 5 + .../buttons/message-generation.ts | 337 +++++++++ src/interactions/commands/chatInput/send.ts | 59 +- src/interactions/index.ts | 36 +- src/interactions/modals/message-generation.ts | 688 ++++++++++++++++++ .../selects/message-generation.ts | 102 +++ src/interactions/shared/message-generation.ts | 235 ++++++ src/lib/messages/cache.ts | 65 ++ src/lib/messages/delete.ts | 34 +- src/lib/messages/edit.ts | 42 +- src/lib/messages/embeds/checks.ts | 47 ++ src/lib/messages/embeds/parser.ts | 21 +- src/lib/messages/embeds/types.ts | 14 +- src/lib/messages/embeds/utils.ts | 23 + src/lib/messages/send.ts | 18 +- src/plugins/redis.ts | 26 + 19 files changed, 1738 insertions(+), 55 deletions(-) create mode 100644 prisma/migrations/20220627005239_add_icons_to_embed/migration.sql create mode 100644 src/interactions/buttons/message-generation.ts create mode 100644 src/interactions/modals/message-generation.ts create mode 100644 src/interactions/selects/message-generation.ts create mode 100644 src/interactions/shared/message-generation.ts create mode 100644 src/lib/messages/cache.ts create mode 100644 src/lib/messages/embeds/checks.ts create mode 100644 src/lib/messages/embeds/utils.ts diff --git a/prisma/migrations/20220627005239_add_icons_to_embed/migration.sql b/prisma/migrations/20220627005239_add_icons_to_embed/migration.sql new file mode 100644 index 0000000..a3691fb --- /dev/null +++ b/prisma/migrations/20220627005239_add_icons_to_embed/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "MessageEmbed" ADD COLUMN "authorIconUrl" TEXT, +ADD COLUMN "authorUrl" TEXT, +ADD COLUMN "footerIconUrl" TEXT, +ADD COLUMN "thumbnailUrl" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 946e540..b8ffe32 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,17 +48,21 @@ model Message { } model MessageEmbed { - id Int @id @default(autoincrement()) - title String? /// @encrypted - description String? /// @encrypted - url String? /// @encrypted - authorName String? /// @encrypted - footerText String? /// @encrypted - timestamp DateTime? - color Int? - fields EmbedField[] - messageId Int @unique - message Message @relation(fields: [messageId], references: [internalId], onDelete: Cascade, onUpdate: NoAction) + id Int @id @default(autoincrement()) + title String? /// @encrypted + description String? /// @encrypted + url String? /// @encrypted + authorName String? /// @encrypted + authorUrl String? /// @encrypted + authorIconUrl String? /// @encrypted + footerText String? /// @encrypted + footerIconUrl String? /// @encrypted + thumbnailUrl String? /// @encrypted + timestamp DateTime? + color Int? + fields EmbedField[] + messageId Int @unique + message Message @relation(fields: [messageId], references: [internalId], onDelete: Cascade, onUpdate: NoAction) } model EmbedField { diff --git a/src/discord_commands/global.json b/src/discord_commands/global.json index c1e2edf..c5c5d1c 100644 --- a/src/discord_commands/global.json +++ b/src/discord_commands/global.json @@ -56,7 +56,7 @@ "type": 7, "name": "channel", "required": false, - "description": "The channel to manage / view permissions of the target on. Leave this blank to manage permissions on the entire server", + "description": "The channel to manage / view permissions of the target on. Leave this blank for the entire server", "channel_types": [0, 5] } ] @@ -92,7 +92,7 @@ "type": 7, "name": "channel", "required": false, - "description": "The channel to setup permissions of the target on. Leave this blank to setup permissions on the entire server", + "description": "The channel to setup permissions of the target on. Leave this blank for the entire server", "channel_types": [0, 5] } ] @@ -135,6 +135,12 @@ "description": "Channel to send the message to", "channel_types": [0, 5, 10, 11, 12], "required": true + }, + { + "name": "content-only", + "type": 5, + "description": "Send a message quickly by only sending content", + "required": false } ] }, diff --git a/src/errors.ts b/src/errors.ts index 088d514..e41548f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -29,6 +29,9 @@ enum InteractionOrRequestFinalStatus { NO_PERMISSIONS_PRESET_SELECTED, MANAGEMENT_PERMISSIONS_CANNOT_BE_SET_ON_CHANNEL_LEVEL, MESSAGE_HAS_NO_CONTENT, + ATTEMPTING_TO_SEND_WHEN_NO_CONTENT_SET, + EMBED_VALUE_EDITING_MALFORMED, + EMBED_EDITING_MISSING_REQUIRED_VALUE, GENERIC_EXPECTED_PERMISSIONS_FAILURE = 3000, USER_MISSING_DISCORD_PERMISSION, BOT_MISSING_DISCORD_PERMISSION, @@ -65,6 +68,8 @@ enum InteractionOrRequestFinalStatus { PERMISSIONS_CANNOT_CROSSOVER_WHEN_UPDATING, TOO_MANY_EMBEDS, MESSAGE_NOT_FOUND_IN_DATABASE_AFTER_CHECKS_DONE, + FIELD_SELECT_OUT_OF_INDEX, + EMBED_EDITING_MISSING_DISCORD_REQUIRED_VALUE, } class CustomError extends Error { diff --git a/src/interactions/buttons/message-generation.ts b/src/interactions/buttons/message-generation.ts new file mode 100644 index 0000000..2dd9c7a --- /dev/null +++ b/src/interactions/buttons/message-generation.ts @@ -0,0 +1,337 @@ +import { + APIEmbed, + APIInteractionResponse, + APIMessageComponentGuildInteraction, + InteractionResponseType, + MessageFlags, +} from "discord-api-types/v9"; +import { FastifyInstance } from "fastify"; + +import { embedPink } from "../../constants"; +import { + InteractionOrRequestFinalStatus, + UnexpectedFailure, +} from "../../errors"; +import { + getMessageFromCache, + MessageSavedInCache, + splitMessageCacheKey, +} from "../../lib/messages/cache"; +import { sendMessage } from "../../lib/messages/send"; +import { GuildSession } from "../../lib/session"; +import { addTipToEmbed } from "../../lib/tips"; +import { InternalInteractionType } from "../interaction"; +import { + createModal, + createTextInputWithRow, +} from "../modals/createStructures"; +import handleMessageGenerationSelect from "../selects/message-generation"; +import { + createEmbedMessageGenerationEmbed, + createInitialMessageGenerationEmbed, + CreateMessageGenerationEmbedResult, + MessageGenerationButtonTypes, +} from "../shared/message-generation"; +import { InteractionReturnData } from "../types"; + +export default async function handleMessageGenerationButton( + internalInteraction: InternalInteractionType, + session: GuildSession, + instance: FastifyInstance +): Promise { + const interaction = internalInteraction.interaction; + const messageGenerationKey = interaction.data.custom_id.split(":")[1] as + | string + | undefined; + const messageGenerationType = interaction.data.custom_id.split(":")[2] as + | MessageGenerationButtonTypes + | undefined; + if ( + messageGenerationKey === undefined || + messageGenerationType === undefined + ) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.COMPONENT_CUSTOM_ID_MALFORMED, + "No message key on message generation button" + ); + } + const currentStatus = await getMessageFromCache({ + key: messageGenerationKey, + instance, + }); + const channelId = splitMessageCacheKey(messageGenerationKey).channelId; + let returnData: CreateMessageGenerationEmbedResult; + switch (messageGenerationType) { + case "content": + return createModal({ + title: "Edit Content", + custom_id: interaction.data.custom_id, // This is the same + components: [ + createTextInputWithRow({ + label: "Message Content", + value: currentStatus.content, + max_length: 2000, + required: false, + custom_id: "content", + short: false, + }), + ], + }); + + case "embed": + returnData = createEmbedMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; + case "embed-metadata": + return createModal({ + title: "Edit Embed Metadata", + custom_id: interaction.data.custom_id, // This is the same + components: [ + createTextInputWithRow({ + label: "Embed Color", + value: currentStatus.embed?.color?.toString(), + + required: false, + custom_id: "color", + placeholder: + "integer color value - use a converter to use hex or rgb", + short: true, + }), + createTextInputWithRow({ + label: "Embed Timestamp", + value: currentStatus.embed?.timestamp, + + required: false, + custom_id: "timestamp", + placeholder: "ISO8601 timestamp", + short: true, + }), + createTextInputWithRow({ + label: "Embed URL", + value: currentStatus.embed?.url, + max_length: 2000, + required: false, + custom_id: "url", + placeholder: "A URL", + short: true, + }), + createTextInputWithRow({ + label: "Embed Thumbnail URL", + value: currentStatus.embed?.thumbnail?.url, + max_length: 2000, + required: false, + custom_id: "thumbnail", + placeholder: "URL of thumbnail", + short: true, + }), + ], + }); + case "embed-footer": + return createModal({ + title: "Edit Embed Footer", + custom_id: interaction.data.custom_id, // This is the same + components: [ + createTextInputWithRow({ + label: "Embed Footer Text", + value: currentStatus.embed?.footer?.text, + max_length: 2048, + required: false, + custom_id: "text", + placeholder: "Footer text", + short: false, + }), + createTextInputWithRow({ + label: "Embed Footer Icon URL", + value: currentStatus.embed?.footer?.icon_url, + max_length: 2000, + required: false, + custom_id: "icon", + placeholder: "URL of footer icon", + short: true, + }), + ], + }); + case "embed-content": + return createModal({ + title: "Edit Embed Content", + custom_id: interaction.data.custom_id, // This is the same + components: [ + createTextInputWithRow({ + label: "Embed Title", + value: currentStatus.embed?.title, + max_length: 256, + required: false, + custom_id: "title", + placeholder: "Title", + short: true, + }), + createTextInputWithRow({ + label: "Embed Description", + value: currentStatus.embed?.description, + max_length: 4000, + required: false, + custom_id: "description", + placeholder: "Description", + short: false, + }), + ], + }); + case "embed-add-field": + return createModal({ + title: "Add Embed Field", + custom_id: interaction.data.custom_id, // This is the same + components: [ + createTextInputWithRow({ + label: "Embed Field Name", + + max_length: 256, + required: true, + custom_id: "name", + placeholder: "Field name", + short: true, + }), + createTextInputWithRow({ + label: "Embed Field Value", + max_length: 1024, + required: true, + custom_id: "value", + placeholder: "Field value", + short: false, + }), + createTextInputWithRow({ + label: "Embed Field Inline", + max_length: 15, + required: false, + custom_id: "inline", + placeholder: "Field inline - default 'false'", + short: true, + }), + ], + }); + case "embed-author": + return createModal({ + title: "Edit Embed Author", + custom_id: interaction.data.custom_id, // This is the same + components: [ + createTextInputWithRow({ + label: "Embed Author Name", + value: currentStatus.embed?.author?.name, + max_length: 256, + required: false, + custom_id: "name", + placeholder: "Author name", + short: true, + }), + createTextInputWithRow({ + label: "Embed Author URL", + value: currentStatus.embed?.author?.url, + max_length: 2000, + required: false, + custom_id: "url", + placeholder: "Author URL", + short: true, + }), + createTextInputWithRow({ + label: "Embed Author Icon URL", + value: currentStatus.embed?.author?.icon_url, + max_length: 2000, + required: false, + custom_id: "icon", + placeholder: "Author icon URL", + short: true, + }), + ], + }); + + case "embed-back": + returnData = createInitialMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; + + case "send": + return await handleSend({ + channelId, + currentStatus, + instance, + session, + interaction, + messageGenerationKey, + }); + + case "select-fields": + return await handleMessageGenerationSelect( + internalInteraction, + session, + instance + ); + + default: + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.COMPONENT_CUSTOM_ID_NOT_FOUND, + "Invalid message generation type" + ); + } +} + +const handleSend = async ({ + channelId, + currentStatus, + instance, + session, + interaction, + messageGenerationKey, +}: { + channelId: string; + currentStatus: MessageSavedInCache; + instance: FastifyInstance; + session: GuildSession; + messageGenerationKey: string; + interaction: APIMessageComponentGuildInteraction; +}): Promise => { + const message = await sendMessage({ + channelId, + content: currentStatus.content, + embed: currentStatus.embed, + instance, + session, + }); + + await instance.redisCache.deleteMessageCache(messageGenerationKey); + + const messageLink = `https://discord.com/channels/${interaction.guild_id}/${channelId}/${message.id}`; + + const embed: APIEmbed = { + color: embedPink, + title: "Message Sent", + description: `Message sent! [Jump to message](${messageLink})`, + url: messageLink, + timestamp: new Date().toISOString(), + }; + + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [addTipToEmbed(embed)], + components: [], + flags: MessageFlags.Ephemeral, + }, + }; +}; diff --git a/src/interactions/commands/chatInput/send.ts b/src/interactions/commands/chatInput/send.ts index ca192de..3905aac 100644 --- a/src/interactions/commands/chatInput/send.ts +++ b/src/interactions/commands/chatInput/send.ts @@ -1,8 +1,11 @@ import { + APIApplicationCommandInteractionDataBooleanOption, APIApplicationCommandInteractionDataChannelOption, APIChatInputApplicationCommandGuildInteraction, ApplicationCommandOptionType, ChannelType, + InteractionResponseType, + MessageFlags, } from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; @@ -10,6 +13,7 @@ import { InteractionOrRequestFinalStatus, UnexpectedFailure, } from "../../../errors"; +import { createMessageCacheKey } from "../../../lib/messages/cache"; import { checkSendMessagePossible, ThreadOptionObject, @@ -20,6 +24,7 @@ import { createModal, createTextInputWithRow, } from "../../modals/createStructures"; +import { createInitialMessageGenerationEmbed } from "../../shared/message-generation"; import { InteractionReturnData } from "../../types"; export default async function handleSendCommand( @@ -49,6 +54,15 @@ export default async function handleSendCommand( "Channel not found in resolved data" ); } + const contentOnly: boolean = + ( + interaction.data.options?.find( + (option) => + option.name === "content-only" && + option.type === ApplicationCommandOptionType.Boolean + ) as APIApplicationCommandInteractionDataBooleanOption + )?.value ?? false; + let threadData: undefined | ThreadOptionObject = undefined; if ( @@ -69,20 +83,35 @@ export default async function handleSendCommand( thread: threadData, session, }); + if (contentOnly) { + return createModal({ + title: `Sending a message to #${channel.name}`, + custom_id: `send:${channelId}`, + components: [ + createTextInputWithRow({ + label: "Message Content", + placeholder: "Message content to send", + max_length: 2000, + min_length: 1, + required: true, + custom_id: "content", + short: false, + }), + ], + }); + } + const messageGenerationKey = createMessageCacheKey(interaction.id, channelId); + const embedData = createInitialMessageGenerationEmbed( + messageGenerationKey, + {} // Empty as this is the start of the process + ); - return createModal({ - title: `Sending a message to #${channel.name}`, - custom_id: `send:${channelId}`, - components: [ - createTextInputWithRow({ - label: "Message Content", - placeholder: "Message content to send", - max_length: 2000, - min_length: 1, - required: true, - custom_id: "content", - short: false, - }), - ], - }); + return { + type: InteractionResponseType.ChannelMessageWithSource, + data: { + embeds: [embedData.embed], + components: embedData.components, + flags: MessageFlags.Ephemeral, + }, + }; } diff --git a/src/interactions/index.ts b/src/interactions/index.ts index 530ff16..cccbcf3 100644 --- a/src/interactions/index.ts +++ b/src/interactions/index.ts @@ -39,6 +39,7 @@ import handleCancelDeleteButton from "./buttons/cancel-delete"; import handleConfirmDeleteButton from "./buttons/confirm-delete"; import handleDeleteButton from "./buttons/delete"; import handleEditButton from "./buttons/edit"; +import handleMessageGenerationButton from "./buttons/message-generation"; import handleReportButton from "./buttons/report"; import handleConfigCommand from "./commands/chatInput/config"; import handleInfoCommand, { @@ -53,6 +54,7 @@ import { InternalInteractionType, } from "./interaction"; import handleModalEdit from "./modals/edit"; +import handleModalMessageGeneration from "./modals/message-generation"; import handleModalReport from "./modals/report"; import handleModalSend from "./modals/send"; import handleManagePermissionsSelect from "./selects/manage-permissions-select"; @@ -394,7 +396,22 @@ class InteractionHandler { ), this._client ); - + case "message-generation": + // Guild only + if (interaction.guild_id === undefined) { + internalInteraction.responded = true; + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.DM_INTERACTION_RECEIVED_WHEN_SHOULD_BE_GUILD_ONLY, + ":exclamation: This modal is only available in guilds" + ); + } + return await handleModalMessageGeneration( + internalInteraction as InternalInteractionType, + this._client.sessionManager.createSessionFromInteraction( + interaction as APIGuildInteraction + ), + this._client + ); default: break; } @@ -511,6 +528,23 @@ class InteractionHandler { this._client ); + case "message-generation": + // Guild only + if (interaction.guild_id === undefined) { + internalInteraction.responded = true; + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.GUILD_COMPONENT_IN_DM_INTERACTION, + ":exclamation: This button is only available in guilds" + ); + } + return await handleMessageGenerationButton( + internalInteraction as InternalInteractionType, + this._client.sessionManager.createSessionFromInteraction( + interaction as APIGuildInteraction + ), + this._client + ); + default: break; } diff --git a/src/interactions/modals/message-generation.ts b/src/interactions/modals/message-generation.ts new file mode 100644 index 0000000..a204528 --- /dev/null +++ b/src/interactions/modals/message-generation.ts @@ -0,0 +1,688 @@ +import { + APIInteractionResponse, + APIModalSubmitGuildInteraction, + InteractionResponseType, + MessageFlags, +} from "discord-api-types/v9"; +import { FastifyInstance } from "fastify"; + +import { + ExpectedFailure, + InteractionOrRequestFinalStatus, + UnexpectedFailure, +} from "../../errors"; +import { + getMessageFromCache, + MessageSavedInCache, + saveMessageToCache, +} from "../../lib/messages/cache"; +import { isIsoDate } from "../../lib/messages/embeds/utils"; +import { GuildSession } from "../../lib/session"; +import { InternalInteractionType } from "../interaction"; +import { + createEmbedMessageGenerationEmbed, + createInitialMessageGenerationEmbed, + MessageGenerationButtonTypes, +} from "../shared/message-generation"; +import { InteractionReturnData } from "../types"; + +export default async function handleModalMessageGeneration( + internalInteraction: InternalInteractionType, + session: GuildSession, + instance: FastifyInstance +): Promise { + const interaction = internalInteraction.interaction; + const messageGenerationKey = interaction.data.custom_id.split(":")[1] as + | string + | undefined; + const messageGenerationType = interaction.data.custom_id.split(":")[2] as + | MessageGenerationButtonTypes + | undefined; + if ( + messageGenerationKey === undefined || + messageGenerationType === undefined + ) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.COMPONENT_CUSTOM_ID_MALFORMED, + "No message id on message generation button" + ); + } + const currentStatus = await getMessageFromCache({ + key: messageGenerationKey, + instance, + }); + + switch (messageGenerationType) { + case "content": + return await handleContent({ + interaction, + currentStatus, + messageGenerationKey, + instance, + }); + + case "embed-metadata": + return await handleEmbedMetadata({ + interaction, + currentStatus, + messageGenerationKey, + instance, + }); + case "embed-footer": + return await handleEmbedFooter({ + interaction, + currentStatus, + messageGenerationKey, + instance, + }); + + case "embed-content": + return await handleEmbedContent({ + interaction, + currentStatus, + messageGenerationKey, + instance, + }); + case "embed-add-field": + return await handleEmbedAddField({ + interaction, + currentStatus, + messageGenerationKey, + instance, + }); + + case "embed-author": + return await handleEmbedAuthor({ + interaction, + currentStatus, + messageGenerationKey, + instance, + }); + + case "edit-embed-field": + return await handleEditEmbedField({ + interaction, + currentStatus, + messageGenerationKey, + instance, + }); + + default: + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.MODAL_CUSTOM_ID_NOT_FOUND, + "Invalid message generation type for modal" + ); + } +} + +const handleContent = async ({ + interaction, + currentStatus, + messageGenerationKey, + instance, +}: { + interaction: APIModalSubmitGuildInteraction; + currentStatus: MessageSavedInCache; + messageGenerationKey: string; + instance: FastifyInstance; +}): Promise => { + const content = interaction.data.components?.find( + (component) => component.components[0].custom_id === "content" + )?.components[0].value; + + if (interaction.channel_id === undefined) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.GENERIC_UNEXPECTED_FAILURE, + "Missing channel_id on modal submit" + ); // Not sure why this might happen + } + currentStatus.content = content; + await saveMessageToCache({ + key: messageGenerationKey, + instance, + data: currentStatus, + }); + + const responseData = createInitialMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [responseData.embed], + components: responseData.components, + flags: MessageFlags.Ephemeral, + }, + }; +}; + +const handleEmbedMetadata = async ({ + interaction, + currentStatus, + messageGenerationKey, + instance, +}: { + interaction: APIModalSubmitGuildInteraction; + currentStatus: MessageSavedInCache; + messageGenerationKey: string; + instance: FastifyInstance; +}): Promise => { + const color = interaction.data.components?.find( + (component) => component.components[0].custom_id === "color" + )?.components[0].value; + const timestamp = interaction.data.components?.find( + (component) => component.components[0].custom_id === "timestamp" + )?.components[0].value; + const url = interaction.data.components?.find( + (component) => component.components[0].custom_id === "url" + )?.components[0].value; + const thumbnailUrl = interaction.data.components?.find( + (component) => component.components[0].custom_id === "thumbnail" + )?.components[0].value; + + // If any are set, and embed is undefined define embed + + if ( + currentStatus.embed === undefined && + ((color !== undefined && color !== "") || + (timestamp !== undefined && timestamp !== "") || + (url !== undefined && url !== "") || + (thumbnailUrl !== undefined && thumbnailUrl !== "")) + ) { + currentStatus.embed = {}; + } else if (currentStatus.embed === undefined) { + // None are set, and none have been set therefore we can safely return + const returnData = createEmbedMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; + } + // If color is set check if it a valid integer color + if (color !== undefined && color !== "") { + if (isNaN(Number(color))) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_VALUE_EDITING_MALFORMED, + "Color is not a valid integer" + ); + } + currentStatus.embed.color = Number(color); + } else { + currentStatus.embed.color = undefined; + } + // If timestamp is set check if it a valid ISO8601 timestamp + if (timestamp !== undefined && timestamp !== "") { + if (!isIsoDate(timestamp)) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_VALUE_EDITING_MALFORMED, + "Timestamp is not a valid ISO8601 timestamp" + ); + } + currentStatus.embed.timestamp = timestamp; + } else { + currentStatus.embed.timestamp = undefined; + } + // If url is set check if it a valid URL + if (url !== undefined && url !== "") { + if (!/^(http|https):\/\/[^ "]+$/.test(url)) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_VALUE_EDITING_MALFORMED, + "URL is not a valid URL" + ); + } + currentStatus.embed.url = url; + } else { + currentStatus.embed.url = undefined; + } + // If thumbnailUrl is set check if it a valid URL + if (thumbnailUrl !== undefined && thumbnailUrl !== "") { + if (!/^(http|https):\/\/[^ "]+$/.test(thumbnailUrl)) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_VALUE_EDITING_MALFORMED, + "Thumbnail URL is not a valid URL" + ); + } + currentStatus.embed.thumbnail = { + url: thumbnailUrl, + }; + } else { + currentStatus.embed.thumbnail = undefined; + } + // Update the message in the cache + await saveMessageToCache({ + key: messageGenerationKey, + instance, + data: currentStatus, + }); + + const returnData = createEmbedMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; +}; + +const handleEmbedFooter = async ({ + interaction, + currentStatus, + messageGenerationKey, + instance, +}: { + interaction: APIModalSubmitGuildInteraction; + currentStatus: MessageSavedInCache; + messageGenerationKey: string; + instance: FastifyInstance; +}): Promise => { + const text = interaction.data.components?.find( + (component) => component.components[0].custom_id === "text" + )?.components[0].value; + const iconUrl = interaction.data.components?.find( + (component) => component.components[0].custom_id === "icon" + )?.components[0].value; + if ( + (text !== undefined && text !== "") || + (iconUrl !== undefined && iconUrl !== "") || + (currentStatus.embed?.footer?.text !== undefined && + currentStatus.embed?.footer?.text !== "") || + (currentStatus.embed?.footer?.icon_url !== undefined && + currentStatus.embed?.footer?.icon_url !== "") + // Only "edit" the embed if new values will be set, or they already have been set + ) { + if (currentStatus.embed === undefined) { + currentStatus.embed = {}; + } + console.log("a"); + if ( + (text === undefined || text === "") && + iconUrl !== undefined && + iconUrl !== "" + ) { + // Text must be set for footer, this means text not set icon set + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_EDITING_MISSING_REQUIRED_VALUE, + "Footer text must be set for any other footer value to be set. Either set a footer text, or remove the icon url." + ); + } + if (text === undefined || text === "") { + // Icon url must also be undefined due to above check + currentStatus.embed.footer = undefined; + console.log("b"); + } else { + if (currentStatus.embed.footer === undefined) { + currentStatus.embed.footer = { text }; + } else { + currentStatus.embed.footer.text = text; + } + console.log("c"); + + if (iconUrl !== undefined && iconUrl !== "") { + // Validate iconurl is a valid url + if (!/^(http|https):\/\/[^ "]+$/.test(iconUrl)) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_VALUE_EDITING_MALFORMED, + "Icon URL is not a valid URL" + ); + } + currentStatus.embed.footer.icon_url = iconUrl; + } + } + console.log("d"); + console.log(currentStatus.embed.footer); + await saveMessageToCache({ + key: messageGenerationKey, + instance, + data: currentStatus, + }); + } + const returnData = createEmbedMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; +}; + +const handleEmbedContent = async ({ + interaction, + currentStatus, + messageGenerationKey, + instance, +}: { + interaction: APIModalSubmitGuildInteraction; + currentStatus: MessageSavedInCache; + messageGenerationKey: string; + instance: FastifyInstance; +}): Promise => { + const title = interaction.data.components?.find( + (component) => component.components[0].custom_id === "title" + )?.components[0].value; + const description = interaction.data.components?.find( + (component) => component.components[0].custom_id === "description" + )?.components[0].value; + if ( + (title !== undefined && title !== "") || + (description !== undefined && description !== "") || + (currentStatus.embed?.title !== undefined && + currentStatus.embed?.title !== "") || + (currentStatus.embed?.description !== undefined && + currentStatus.embed?.description !== "") + ) { + if (currentStatus.embed === undefined) { + currentStatus.embed = {}; + } + if (title !== undefined && title !== "") { + currentStatus.embed.title = title; + } else { + currentStatus.embed.title = undefined; + } + if (description !== undefined && description !== "") { + currentStatus.embed.description = description; + } else { + currentStatus.embed.description = undefined; + } + await saveMessageToCache({ + key: messageGenerationKey, + instance, + data: currentStatus, + }); + } + const returnData = createEmbedMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; +}; + +const parseTrueLikeValues = (value: string | undefined): boolean => { + // 'true' 't' 'y' 'yes' 'on' '1' - true + // 'false' 'f' 'n' 'no' 'off' '0' and any other value - false + // Case insensitive + if (value === undefined) { + return false; + } + const parsed = value.toLowerCase(); + return ( + parsed === "true" || + parsed === "t" || + parsed === "y" || + parsed === "yes" || + parsed === "on" || + parsed === "1" + ); +}; + +const handleEmbedAddField = async ({ + interaction, + currentStatus, + messageGenerationKey, + instance, +}: { + interaction: APIModalSubmitGuildInteraction; + currentStatus: MessageSavedInCache; + messageGenerationKey: string; + instance: FastifyInstance; +}): Promise => { + const fieldName = interaction.data.components?.find( + (component) => component.components[0].custom_id === "name" + )?.components[0].value; + const fieldValue = interaction.data.components?.find( + (component) => component.components[0].custom_id === "value" + )?.components[0].value; + const inline = parseTrueLikeValues( + interaction.data.components?.find( + (component) => component.components[0].custom_id === "inline" + )?.components[0].value + ); + if ( + fieldName === undefined || + fieldName === "" || + fieldValue === undefined || + fieldValue === "" + ) { + // Name and value must be set, as this is adding a field + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.EMBED_EDITING_MISSING_DISCORD_REQUIRED_VALUE, + "Field name and value must be set." + ); + } + + if (currentStatus.embed === undefined) { + currentStatus.embed = {}; + } + if (currentStatus.embed.fields === undefined) { + currentStatus.embed.fields = []; + } + + currentStatus.embed.fields.push({ + name: fieldName, + value: fieldValue, + inline, + }); + + await saveMessageToCache({ + key: messageGenerationKey, + instance, + data: currentStatus, + }); + + const returnData = createEmbedMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; +}; + +const handleEditEmbedField = async ({ + interaction, + currentStatus, + messageGenerationKey, + instance, +}: { + interaction: APIModalSubmitGuildInteraction; + currentStatus: MessageSavedInCache; + messageGenerationKey: string; + instance: FastifyInstance; +}): Promise => { + const fieldName = interaction.data.components?.find( + (component) => component.components[0].custom_id === "name" + )?.components[0].value; + const fieldValue = interaction.data.components?.find( + (component) => component.components[0].custom_id === "value" + )?.components[0].value; + const inline = parseTrueLikeValues( + interaction.data.components?.find( + (component) => component.components[0].custom_id === "inline" + )?.components[0].value + ); + const fieldIndex = Number(interaction.data.custom_id.split(":")[3]); + + if ( + (fieldName === undefined || fieldName === "") && + (fieldValue === undefined || fieldValue === "") + ) { + // Clear the field if name or value is not set + if (currentStatus.embed?.fields?.[fieldIndex] !== undefined) { + currentStatus.embed.fields.splice(fieldIndex, 1); + } + } else { + // Otherwise, update the field + // Both name and value must be set + if ( + fieldName === undefined || + fieldName === "" || + fieldValue === undefined || + fieldValue === "" + ) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.EMBED_EDITING_MISSING_DISCORD_REQUIRED_VALUE, + "Field name and value both must be set. To remove the field make sure both are empty." + ); + } + if (currentStatus.embed === undefined) { + currentStatus.embed = {}; + } + if (currentStatus.embed.fields === undefined) { + currentStatus.embed.fields = []; + } + if (currentStatus.embed.fields[fieldIndex] === undefined) { + currentStatus.embed.fields.push({ + name: fieldName, + value: fieldValue, + inline, + }); + } else { + currentStatus.embed.fields[fieldIndex] = { + name: fieldName, + value: fieldValue, + inline, + }; + } + } + + await saveMessageToCache({ + key: messageGenerationKey, + instance, + data: currentStatus, + }); + const returnData = createEmbedMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; +}; + +const handleEmbedAuthor = async ({ + interaction, + currentStatus, + messageGenerationKey, + instance, +}: { + interaction: APIModalSubmitGuildInteraction; + currentStatus: MessageSavedInCache; + + messageGenerationKey: string; + instance: FastifyInstance; +}): Promise => { + // Handle author name, url, and icon url + // Author name **must be set** if any values are set + const authorName = interaction.data.components?.find( + (component) => component.components[0].custom_id === "name" + )?.components[0].value; + const authorUrl = interaction.data.components?.find( + (component) => component.components[0].custom_id === "url" + )?.components[0].value; + const authorIconUrl = interaction.data.components?.find( + (component) => component.components[0].custom_id === "icon" + )?.components[0].value; + if ( + (authorName !== undefined && authorName !== "") || + (authorUrl !== undefined && authorUrl !== "") || + (authorIconUrl !== undefined && authorIconUrl !== "") || + currentStatus.embed?.author?.name !== undefined // Only need to check name, as it must be set if any other is set + ) { + if (authorName === undefined || authorName === "") { + if ( + (authorUrl !== undefined && authorUrl !== "") || + (authorIconUrl !== undefined && authorIconUrl !== "") + ) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_EDITING_MISSING_REQUIRED_VALUE, + "Author name must be set for any other author value to be set. Either set an author name, or remove the author url or author icon url." + ); + } + if (currentStatus.embed !== undefined) { + currentStatus.embed.author = undefined; + } + } else { + if (currentStatus.embed === undefined) { + currentStatus.embed = {}; + } + if (currentStatus.embed.author === undefined) { + currentStatus.embed.author = { name: authorName }; + } else { + currentStatus.embed.author.name = authorName; + } + + if (authorUrl !== undefined && authorUrl !== "") { + // Validate authorurl is a valid url + if (!/^(http|https):\/\/[^ "]+$/.test(authorUrl)) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_VALUE_EDITING_MALFORMED, + "Author URL is not a valid URL" + ); + } + currentStatus.embed.author.url = authorUrl; + } + if (authorIconUrl !== undefined && authorIconUrl !== "") { + // Validate authoriconurl is a valid url + if (!/^(http|https):\/\/[^ "]+$/.test(authorIconUrl)) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_VALUE_EDITING_MALFORMED, + "Author Icon URL is not a valid URL" + ); + } + currentStatus.embed.author.icon_url = authorIconUrl; + } + } + await saveMessageToCache({ + key: messageGenerationKey, + instance, + data: currentStatus, + }); + } + const returnData = createEmbedMessageGenerationEmbed( + messageGenerationKey, + currentStatus + ); + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [returnData.embed], + components: returnData.components, + flags: MessageFlags.Ephemeral, + }, + }; +}; diff --git a/src/interactions/selects/message-generation.ts b/src/interactions/selects/message-generation.ts new file mode 100644 index 0000000..707fafb --- /dev/null +++ b/src/interactions/selects/message-generation.ts @@ -0,0 +1,102 @@ +import { + APIMessageComponentGuildInteraction, + APIMessageSelectMenuInteractionData, +} from "discord-api-types/v9"; +import { FastifyInstance } from "fastify"; + +import { + InteractionOrRequestFinalStatus, + UnexpectedFailure, +} from "../../errors"; +import { getMessageFromCache } from "../../lib/messages/cache"; +import { GuildSession } from "../../lib/session"; +import { InternalInteractionType } from "../interaction"; +import { + createModal, + createTextInputWithRow, +} from "../modals/createStructures"; +import { + generateMessageGenerationCustomId, + MessageGenerationButtonTypes, +} from "../shared/message-generation"; +import { InteractionReturnData } from "../types"; + +export default async function handleMessageGenerationSelect( + internalInteraction: InternalInteractionType, + session: GuildSession, + instance: FastifyInstance +): Promise { + const interaction = internalInteraction.interaction; + const customIdData = interaction.data.custom_id.split(":"); + const messageGenerationKey = customIdData[1] as string | undefined; + const messageGenerationType = customIdData[2] as + | MessageGenerationButtonTypes + | undefined; + if ( + messageGenerationKey === undefined || + messageGenerationType === undefined + ) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.COMPONENT_CUSTOM_ID_MALFORMED, + "No message key on message generation button" + ); + } + const currentStatus = await getMessageFromCache({ + key: messageGenerationKey, + instance, + }); + const index = parseInt( + (interaction.data as APIMessageSelectMenuInteractionData).values[0] + ); + const currentField = currentStatus.embed?.fields?.[index]; + console.log(index); + + if (currentField === undefined) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.FIELD_SELECT_OUT_OF_INDEX, + "Fields out of index" + ); + } + return createModal({ + title: "Edit Field", + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "edit-embed-field", + index + ), + components: [ + createTextInputWithRow({ + label: "Embed Field Name", + value: currentField.name, + max_length: 256, + required: false, + custom_id: "name", + placeholder: "Field name", + short: true, + }), + createTextInputWithRow({ + label: "Embed Field Value", + value: currentField.value, + max_length: 1024, + required: false, + custom_id: "value", + placeholder: "Field value", + short: false, + }), + createTextInputWithRow({ + label: "Embed Field Inline", + value: + currentField.inline !== undefined + ? currentField.inline + ? "true" + : "false" + : undefined, + max_length: 15, + required: false, + custom_id: "inline", + placeholder: "Field inline - default 'false'", + short: true, + }), + ], + }); +} diff --git a/src/interactions/shared/message-generation.ts b/src/interactions/shared/message-generation.ts new file mode 100644 index 0000000..9ec7bf6 --- /dev/null +++ b/src/interactions/shared/message-generation.ts @@ -0,0 +1,235 @@ +import { + APIActionRowComponent, + APIEmbed, + APIMessageActionRowComponent, + APISelectMenuComponent, + ButtonStyle, + ComponentType, +} from "discord-api-types/v9"; + +import { embedPink } from "../../constants"; +import { MessageSavedInCache } from "../../lib/messages/cache"; +import { addTipToEmbed } from "../../lib/tips"; + +type MessageGenerationButtonTypes = + | "content" + | "embed" + | "send" + | "embed-metadata" + | "select-fields" + | "embed-footer" + | "embed-content" + | "embed-add-field" + | "embed-author" + | "embed-back" + | "edit-embed-field"; +const generateMessageGenerationCustomId = ( + messageGenerationKey: string, + type: MessageGenerationButtonTypes, + index?: number +): string => { + return `message-generation:${messageGenerationKey}:${type}${ + index !== undefined ? `:${index}` : "" + }`; +}; + +interface CreateMessageGenerationEmbedResult { + embed: APIEmbed; + components: APIActionRowComponent[]; +} +const createInitialMessageGenerationEmbed = ( + messageGenerationKey: string, + currentStatus: MessageSavedInCache +): CreateMessageGenerationEmbedResult => { + return { + embed: addTipToEmbed({ + title: "Message Generation Flow", + description: + "Use the buttons below to update the state of the message and embed. When you are done, click the send button." + + "\n\n" + + `**Current Content**: ${currentStatus.content ?? ""}` + + "\n\n" + + `${ + currentStatus.embed !== undefined + ? "Embed exists, click the edit embed button to edit it." + : "" + }`, + color: embedPink, + }), + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: "Edit Content", + style: ButtonStyle.Primary, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "content" + ), + }, + { + type: ComponentType.Button, + label: "Edit Embed", + style: ButtonStyle.Primary, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "embed" + ), + }, + + { + type: ComponentType.Button, + label: "Send", + style: ButtonStyle.Success, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "send" + ), + }, + ], + }, + ], + }; +}; + +const createEmbedMessageGenerationEmbed = ( + messageGenerationKey: string, + currentStatus: MessageSavedInCache +): CreateMessageGenerationEmbedResult => { + let selectMenu: APIActionRowComponent; + if ( + currentStatus.embed?.fields?.length !== undefined && + currentStatus.embed.fields.length > 0 + ) { + selectMenu = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.SelectMenu, + placeholder: "Select a field to edit", + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "select-fields" + ), + max_values: 1, + min_values: 1, + options: currentStatus.embed.fields.map((field, index) => { + return { + label: field.name, + value: index.toString(), + }; + }), + }, + ], + }; + } else { + selectMenu = { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.SelectMenu, + placeholder: "Select a field to edit", + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "select-fields" + ), + max_values: 1, + min_values: 1, + options: [ + { + label: "No fields to edit", + value: "0", + default: true, + }, + ], + disabled: true, + }, + ], + }; + } + return { + embed: addTipToEmbed({ + title: "Message Generation Flow - Embed", + description: + "Use the buttons below to update the state of the embed. When you are done, click the back button.", + color: embedPink, + }), + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: "Edit Metadata", + style: ButtonStyle.Primary, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "embed-metadata" + ), + }, + { + type: ComponentType.Button, + label: "Edit Content", + style: ButtonStyle.Primary, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "embed-content" + ), + }, + { + type: ComponentType.Button, + label: "Edit Footer", + style: ButtonStyle.Primary, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "embed-footer" + ), + }, + { + type: ComponentType.Button, + label: "Edit Author", + style: ButtonStyle.Primary, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "embed-author" + ), + }, + { + type: ComponentType.Button, + label: "Add Field", + style: ButtonStyle.Primary, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "embed-add-field" + ), + }, + ], + }, + selectMenu, + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: "Save & Back", + style: ButtonStyle.Success, + custom_id: generateMessageGenerationCustomId( + messageGenerationKey, + "embed-back" + ), + }, + ], + }, + ], + }; +}; + +export { + createEmbedMessageGenerationEmbed, + createInitialMessageGenerationEmbed, + CreateMessageGenerationEmbedResult, + generateMessageGenerationCustomId, + MessageGenerationButtonTypes, +}; diff --git a/src/lib/messages/cache.ts b/src/lib/messages/cache.ts new file mode 100644 index 0000000..6c31008 --- /dev/null +++ b/src/lib/messages/cache.ts @@ -0,0 +1,65 @@ +// This manages the currently editing messages stored in cache + +import { Snowflake } from "discord-api-types/globals"; +import { FastifyInstance } from "fastify"; + +import { StoredEmbed } from "./embeds/types"; + +const createMessageCacheKey = ( + interactionId: Snowflake, + channelId: Snowflake +): string => { + return `${interactionId}-${channelId}`; // use - to separate to avoid collisions with custom_id +}; + +const splitMessageCacheKey = ( + key: string +): { interactionId: Snowflake; channelId: Snowflake } => { + const [interactionId, channelId] = key.split("-"); + return { + interactionId, + channelId, + }; +}; + +interface MessageSavedInCache { + content?: string; + embed?: StoredEmbed; +} + +const saveMessageToCache = ({ + key, + data, + instance, +}: { + key: string; + data: MessageSavedInCache; + instance: FastifyInstance; +}): Promise => { + return instance.redisCache.setMessageCache(key, data); +}; + +const getMessageFromCache = async ({ + key, + instance, +}: { + key: string; + instance: FastifyInstance; +}): Promise => { + const message = await instance.redisCache.getMessageCache(key); + if (message === null) { + return { + embed: undefined, + content: undefined, + }; + } + return message; +}; + +export { + createMessageCacheKey, + getMessageFromCache, + MessageSavedInCache, + saveMessageToCache, + splitMessageCacheKey, +}; diff --git a/src/lib/messages/delete.ts b/src/lib/messages/delete.ts index d19d0bd..38419ba 100644 --- a/src/lib/messages/delete.ts +++ b/src/lib/messages/delete.ts @@ -1,6 +1,12 @@ import { DiscordAPIError, RawFile } from "@discordjs/rest"; import { Message } from "@prisma/client"; -import { APIEmbed, Routes, Snowflake } from "discord-api-types/v9"; +import { + APIEmbed, + APIEmbedAuthor, + APIEmbedFooter, + Routes, + Snowflake, +} from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; import { embedPink } from "../../constants"; @@ -168,15 +174,37 @@ async function deleteMessage({ }; let embedBefore: StoredEmbed | undefined = undefined; if (messageBefore?.embed !== null && messageBefore?.embed !== undefined) { + let footer: APIEmbedFooter | undefined = undefined; + if (messageBefore.embed.footerText !== null) { + footer = { + text: messageBefore.embed.footerText, + icon_url: messageBefore.embed.footerIconUrl ?? undefined, + }; + } + let author: APIEmbedAuthor | undefined = undefined; + if (messageBefore.embed.authorName !== null) { + author = { + name: messageBefore.embed.authorName, + url: messageBefore.embed.authorUrl ?? undefined, + icon_url: messageBefore.embed.authorIconUrl ?? undefined, + }; + } + embedBefore = { title: messageBefore.embed.title ?? undefined, description: messageBefore.embed.description ?? undefined, url: messageBefore.embed.url ?? undefined, timestamp: messageBefore.embed.timestamp?.toISOString() ?? undefined, color: messageBefore.embed.color ?? undefined, - footerText: messageBefore.embed.footerText ?? undefined, - authorName: messageBefore.embed.authorName ?? undefined, + footer: footer, + author: author, fields: messageBefore.embed.fields ?? undefined, + thumbnail: + messageBefore.embed.thumbnailUrl !== null + ? { + url: messageBefore.embed.thumbnailUrl, + } + : undefined, }; } diff --git a/src/lib/messages/edit.ts b/src/lib/messages/edit.ts index 2af5bfd..3529b68 100644 --- a/src/lib/messages/edit.ts +++ b/src/lib/messages/edit.ts @@ -1,6 +1,12 @@ import { DiscordAPIError, RawFile } from "@discordjs/rest"; import { Message, Prisma } from "@prisma/client"; -import { APIEmbed, Routes, Snowflake } from "discord-api-types/v9"; +import { + APIEmbed, + APIEmbedAuthor, + APIEmbedFooter, + Routes, + Snowflake, +} from "discord-api-types/v9"; import { RESTPatchAPIChannelMessageResult } from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; @@ -166,8 +172,12 @@ async function editMessage({ url: sentEmbed.url, timestamp, color: sentEmbed.color, - footerText: sentEmbed.footerText, - authorName: sentEmbed.authorName, + footerText: sentEmbed.footer?.text, + footerIconUrl: sentEmbed.footer?.icon_url, + authorName: sentEmbed.author?.name, + authorIconUrl: sentEmbed.author?.icon_url, + authorUrl: sentEmbed.author?.url, + thumbnailUrl: sentEmbed.thumbnail?.url, fields: fieldQuery, }, }; @@ -252,15 +262,37 @@ async function editMessage({ }; let embedBefore: StoredEmbed | undefined = undefined; if (messageBefore?.embed !== null && messageBefore?.embed !== undefined) { + let footer: APIEmbedFooter | undefined = undefined; + if (messageBefore.embed.footerText !== null) { + footer = { + text: messageBefore.embed.footerText, + icon_url: messageBefore.embed.footerIconUrl ?? undefined, + }; + } + let author: APIEmbedAuthor | undefined = undefined; + if (messageBefore.embed.authorName !== null) { + author = { + name: messageBefore.embed.authorName, + url: messageBefore.embed.authorUrl ?? undefined, + icon_url: messageBefore.embed.authorIconUrl ?? undefined, + }; + } + embedBefore = { title: messageBefore.embed.title ?? undefined, description: messageBefore.embed.description ?? undefined, url: messageBefore.embed.url ?? undefined, timestamp: messageBefore.embed.timestamp?.toISOString() ?? undefined, color: messageBefore.embed.color ?? undefined, - footerText: messageBefore.embed.footerText ?? undefined, - authorName: messageBefore.embed.authorName ?? undefined, + footer: footer, + author: author, fields: messageBefore.embed.fields ?? undefined, + thumbnail: + messageBefore.embed.thumbnailUrl !== null + ? { + url: messageBefore.embed.thumbnailUrl, + } + : undefined, }; } diff --git a/src/lib/messages/embeds/checks.ts b/src/lib/messages/embeds/checks.ts new file mode 100644 index 0000000..1224aa4 --- /dev/null +++ b/src/lib/messages/embeds/checks.ts @@ -0,0 +1,47 @@ +import { StoredEmbed } from "./types"; + +// Sum of title, description, field.name, field.value, footer.text, and author.name must not exceed 6000 characters + +// Check if an embed exceeds any of the limits +function checkExceedsEmbedLimits(embed: StoredEmbed): boolean { + let totalCheckableLength = 0; + // First check if each individual part of the embed exceeds it's limit, only for parts that have their own limits + if (embed.title !== undefined && embed.title.length > 256) { + return true; + } + totalCheckableLength += embed.title?.length ?? 0; + if (embed.description !== undefined && embed.description.length > 4096) { + return true; + } + totalCheckableLength += embed.description?.length ?? 0; + if (embed.footer?.text !== undefined && embed.footer.text.length > 2048) { + return true; + } + totalCheckableLength += embed.footer?.text.length ?? 0; + if (embed.author?.name !== undefined && embed.author.name.length > 256) { + return true; + } + totalCheckableLength += embed.author?.name.length ?? 0; + if (embed.fields && embed.fields.length > 25) { + return true; + } + // Also check each field, and ensure it doesn't exceed it's limit + if (embed.fields) { + for (const field of embed.fields) { + if (field.name && field.name.length > 256) { + return true; + } + if (field.value && field.value.length > 1024) { + return true; + } + totalCheckableLength += + field.name?.length ?? 0 + field.value?.length ?? 0; + } + } + if (totalCheckableLength > 6000) { + return true; + } + return false; +} + +export { checkExceedsEmbedLimits as exceedsEmbedLimits }; diff --git a/src/lib/messages/embeds/parser.ts b/src/lib/messages/embeds/parser.ts index 4c5c082..a4bf11b 100644 --- a/src/lib/messages/embeds/parser.ts +++ b/src/lib/messages/embeds/parser.ts @@ -25,8 +25,9 @@ const createStoredEmbedFromAPIMessage = ( url: embed.url, timestamp: embed.timestamp, color: embed.color, - footerText: embed.footer?.text, - authorName: embed.author?.name, + footer: embed.footer, + author: embed.author, + thumbnail: embed.thumbnail, fields: embed.fields, }; }; @@ -37,22 +38,16 @@ const createSendableEmbedFromStoredEmbed = (embed: StoredEmbed): APIEmbed => { description: embed.description, url: embed.url, timestamp: embed.timestamp, + footer: embed.footer, + author: embed.author, + thumbnail: embed.thumbnail, color: embed.color, }; - if (embed.footerText !== undefined) { - sendableEmbed.footer = { - text: embed.footerText, - }; - } - if (embed.authorName !== undefined) { - sendableEmbed.author = { - name: embed.authorName, - }; - } + if (embed.fields !== undefined) { sendableEmbed.fields = embed.fields; } return sendableEmbed; }; -export { createSendableEmbedFromStoredEmbed,createStoredEmbedFromAPIMessage }; +export { createSendableEmbedFromStoredEmbed, createStoredEmbedFromAPIMessage }; diff --git a/src/lib/messages/embeds/types.ts b/src/lib/messages/embeds/types.ts index d860bd1..233f757 100644 --- a/src/lib/messages/embeds/types.ts +++ b/src/lib/messages/embeds/types.ts @@ -10,8 +10,18 @@ interface StoredEmbed { url?: string; timestamp?: string; color?: number; - footerText?: string; - authorName?: string; + footer?: { + text: string; // Max 2048 characters + icon_url?: string; + }; + author?: { + name: string; // Max 256 characters + url?: string; + icon_url?: string; + }; + thumbnail?: { + url: string; // Max 2048 characters + }; fields?: StoredField[]; // Max 25 } diff --git a/src/lib/messages/embeds/utils.ts b/src/lib/messages/embeds/utils.ts new file mode 100644 index 0000000..c7859c8 --- /dev/null +++ b/src/lib/messages/embeds/utils.ts @@ -0,0 +1,23 @@ +// github.com/segment-boneyard/is-isodate/blob/master/index.js + +/** + * ISO date matcher. + * + * http://www.w3.org/TR/NOTE-datetime + */ + +const matcher = new RegExp( + "^\\d{4}-\\d{2}-\\d{2}" + // Match YYYY-MM-DD + "((T\\d{2}:\\d{2}(:\\d{2})?)" + // Match THH:mm:ss + "(\\.\\d{1,6})?" + // Match .sssss + "(Z|(\\+|-)\\d{2}:\\d{2})?)?$" // Time zone (Z or +hh:mm) +); + +function isIsoDate(string: string) { + return ( + typeof string === "string" && + matcher.test(string) && + !isNaN(Date.parse(string)) + ); +} +export { isIsoDate }; diff --git a/src/lib/messages/send.ts b/src/lib/messages/send.ts index 84ba655..905e046 100644 --- a/src/lib/messages/send.ts +++ b/src/lib/messages/send.ts @@ -12,6 +12,7 @@ import { FastifyInstance } from "fastify"; import { embedPink } from "../../constants"; import { parseDiscordPermissionValuesToStringNames } from "../../consts"; import { + ExpectedFailure, ExpectedPermissionFailure, InteractionOrRequestFinalStatus, UnexpectedFailure, @@ -54,7 +55,7 @@ interface CheckSendMessageOptions { } interface SendMessageOptions extends CheckSendMessageOptions { - content: string; + content?: string; embed?: StoredEmbed; } @@ -134,6 +135,13 @@ async function sendMessage({ thread, session, }); + + if ((content === undefined || content === "") && embed === undefined) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.ATTEMPTING_TO_SEND_WHEN_NO_CONTENT_SET, + "No content or embeds have been set, this is required to send a message" + ); + } const embeds: APIEmbed[] = []; if (embed) { embeds.push(createSendableEmbedFromStoredEmbed(embed)); @@ -175,8 +183,12 @@ async function sendMessage({ url: sentEmbed.url, timestamp, color: sentEmbed.color, - footerText: sentEmbed.footerText, - authorName: sentEmbed.authorName, + footerText: sentEmbed.footer?.text, + footerIconUrl: sentEmbed.footer?.icon_url, + authorName: sentEmbed.author?.name, + authorIconUrl: sentEmbed.author?.icon_url, + authorUrl: sentEmbed.author?.url, + thumbnailUrl: sentEmbed.thumbnail?.url, fields: fieldQuery, }, }; diff --git a/src/plugins/redis.ts b/src/plugins/redis.ts index d8e13d8..7e9c1f8 100644 --- a/src/plugins/redis.ts +++ b/src/plugins/redis.ts @@ -4,6 +4,7 @@ import fp from "fastify-plugin"; import RedisClient, { Redis } from "ioredis"; import { StoredStateResponse } from "../authRoutes"; +import { MessageSavedInCache } from "../lib/messages/cache"; type ArgType = Array; class RedisCache { @@ -94,6 +95,31 @@ class RedisCache { deleteState(state: string): Promise { return this._delete({ key: `state:${state}` }); } + + async setMessageCache( + key: string, + message: MessageSavedInCache + ): Promise { + key = `message:${key}`; + await this._set({ + key, + value: JSON.stringify(message), + }); + } + async getMessageCache(key: string): Promise { + const data = await this._get({ key: `message:${key}` }); + + // We do not know what the data is, so we use falsy values + // Also an empty string should be returned as undefined anyways + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (data) { + return JSON.parse(data as string) as MessageSavedInCache; + } + return null; + } + async deleteMessageCache(key: string): Promise { + return this._delete({ key: `message:${key}` }); + } async setSession(session: string, userId: Snowflake): Promise { const key = `session:${session}`; await this._set({ key, value: JSON.stringify(userId) }); From e5581347f48df66d848cf77073bf90ba7663dd53 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Mon, 27 Jun 2022 13:01:17 +1200 Subject: [PATCH 04/14] feat: pretty print embed files --- src/lib/messages/delete.ts | 2 +- src/lib/messages/edit.ts | 4 ++-- src/lib/messages/send.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/messages/delete.ts b/src/lib/messages/delete.ts index 38419ba..2c88037 100644 --- a/src/lib/messages/delete.ts +++ b/src/lib/messages/delete.ts @@ -212,7 +212,7 @@ async function deleteMessage({ if (embedBefore !== undefined) { files.push({ name: "embed.json", - data: JSON.stringify(embedBefore), + data: JSON.stringify(embedBefore, undefined, 2), }); logEmbed.description += "\nEmbed representation can be found in the attachment."; diff --git a/src/lib/messages/edit.ts b/src/lib/messages/edit.ts index 3529b68..3eaf9b7 100644 --- a/src/lib/messages/edit.ts +++ b/src/lib/messages/edit.ts @@ -304,11 +304,11 @@ async function editMessage({ ) { files.push({ name: "embed-before.json", - data: JSON.stringify(embedBefore), + data: JSON.stringify(embedBefore, undefined, 2), }); files.push({ name: "embed-after.json", - data: JSON.stringify(sentEmbed), + data: JSON.stringify(sentEmbed, undefined, 2), }); logEmbed.description += "\nEmbed representation can be found in the attachment."; diff --git a/src/lib/messages/send.ts b/src/lib/messages/send.ts index 905e046..9c61e4f 100644 --- a/src/lib/messages/send.ts +++ b/src/lib/messages/send.ts @@ -243,7 +243,7 @@ async function sendMessage({ if (sentEmbed !== null) { files.push({ name: "embed.json", - data: JSON.stringify(sentEmbed), + data: JSON.stringify(sentEmbed, undefined, 2), }); logEmbed.description += "\nEmbed representation can be found in the attachment."; From 9ce1884328c4f239944a171c780c9e03ad3d0b45 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Mon, 27 Jun 2022 13:05:46 +1200 Subject: [PATCH 05/14] fix: check if embed exceeds limits on save --- src/errors.ts | 1 + src/lib/messages/cache.ts | 12 ++++++++++++ src/lib/messages/embeds/checks.ts | 4 ++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index e41548f..6f51880 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -46,6 +46,7 @@ enum InteractionOrRequestFinalStatus { MAX_USER_PERMISSIONS, MAX_ROLE_CHANNEL_PERMISSIONS, MAX_USER_CHANNEL_PERMISSIONS, + EMBED_EXCEEDS_DISCORD_LIMITS, GENERIC_UNEXPECTED_FAILURE = 6000, INTERACTION_TYPE_MISSING_HANDLER, APPLICATION_COMMAND_TYPE_MISSING_HANDLER, diff --git a/src/lib/messages/cache.ts b/src/lib/messages/cache.ts index 6c31008..d0d4aa4 100644 --- a/src/lib/messages/cache.ts +++ b/src/lib/messages/cache.ts @@ -3,6 +3,8 @@ import { Snowflake } from "discord-api-types/globals"; import { FastifyInstance } from "fastify"; +import { ExpectedFailure, InteractionOrRequestFinalStatus } from "../../errors"; +import { checkEmbedMeetsLimits } from "./embeds/checks"; import { StoredEmbed } from "./embeds/types"; const createMessageCacheKey = ( @@ -36,6 +38,16 @@ const saveMessageToCache = ({ data: MessageSavedInCache; instance: FastifyInstance; }): Promise => { + // Check if embed exceeds limits + if (data.embed !== undefined) { + const exceedsLimits = checkEmbedMeetsLimits(data.embed); + if (exceedsLimits) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_EXCEEDS_DISCORD_LIMITS, + "The embed exceeds one or more of limits on embeds." + ); + } + } return instance.redisCache.setMessageCache(key, data); }; diff --git a/src/lib/messages/embeds/checks.ts b/src/lib/messages/embeds/checks.ts index 1224aa4..b289aef 100644 --- a/src/lib/messages/embeds/checks.ts +++ b/src/lib/messages/embeds/checks.ts @@ -3,7 +3,7 @@ import { StoredEmbed } from "./types"; // Sum of title, description, field.name, field.value, footer.text, and author.name must not exceed 6000 characters // Check if an embed exceeds any of the limits -function checkExceedsEmbedLimits(embed: StoredEmbed): boolean { +function checkEmbedMeetsLimits(embed: StoredEmbed): boolean { let totalCheckableLength = 0; // First check if each individual part of the embed exceeds it's limit, only for parts that have their own limits if (embed.title !== undefined && embed.title.length > 256) { @@ -44,4 +44,4 @@ function checkExceedsEmbedLimits(embed: StoredEmbed): boolean { return false; } -export { checkExceedsEmbedLimits as exceedsEmbedLimits }; +export { checkEmbedMeetsLimits }; From a4532b52cd395f512f7627a4841be31af89e9e13 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Tue, 28 Jun 2022 09:20:44 +1200 Subject: [PATCH 06/14] feat: add check for embed limits on send --- src/lib/messages/edit.ts | 12 ++++++++++++ src/lib/messages/send.ts | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/lib/messages/edit.ts b/src/lib/messages/edit.ts index 3eaf9b7..9b7ff1a 100644 --- a/src/lib/messages/edit.ts +++ b/src/lib/messages/edit.ts @@ -13,6 +13,7 @@ import { FastifyInstance } from "fastify"; import { embedPink } from "../../constants"; import { parseDiscordPermissionValuesToStringNames } from "../../consts"; import { + ExpectedFailure, ExpectedPermissionFailure, InteractionOrRequestFinalStatus, UnexpectedFailure, @@ -22,6 +23,7 @@ import { InternalPermissions } from "../permissions/consts"; import { GuildSession } from "../session"; import { checkDatabaseMessage } from "./checks"; import { requiredPermissionsEdit } from "./consts"; +import { checkEmbedMeetsLimits } from "./embeds/checks"; import { createSendableEmbedFromStoredEmbed, createStoredEmbedFromAPIMessage, @@ -121,6 +123,16 @@ async function editMessage({ }: EditMessageOptions) { await checkEditPossible({ channelId, instance, messageId, session }); try { + // Check if embed exceeds limits + if (embed !== undefined) { + const exceedsLimits = checkEmbedMeetsLimits(embed); + if (exceedsLimits) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_EXCEEDS_DISCORD_LIMITS, + "The embed exceeds one or more of limits on embeds." + ); + } + } const embeds: APIEmbed[] = []; if (embed) { embeds.push(createSendableEmbedFromStoredEmbed(embed)); diff --git a/src/lib/messages/send.ts b/src/lib/messages/send.ts index 9c61e4f..6418fd9 100644 --- a/src/lib/messages/send.ts +++ b/src/lib/messages/send.ts @@ -24,6 +24,7 @@ import { requiredPermissionsSendBotThread, requiredPermissionsSendUser, } from "./consts"; +import { checkEmbedMeetsLimits } from "./embeds/checks"; import { createSendableEmbedFromStoredEmbed, createStoredEmbedFromAPIMessage, @@ -142,6 +143,16 @@ async function sendMessage({ "No content or embeds have been set, this is required to send a message" ); } + // Check if embed exceeds limits + if (embed !== undefined) { + const exceedsLimits = checkEmbedMeetsLimits(embed); + if (exceedsLimits) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_EXCEEDS_DISCORD_LIMITS, + "The embed exceeds one or more of limits on embeds." + ); + } + } const embeds: APIEmbed[] = []; if (embed) { embeds.push(createSendableEmbedFromStoredEmbed(embed)); From 0eff48ffecb3f2283ce9d22d34737b1bc27c43b8 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Tue, 28 Jun 2022 09:24:41 +1200 Subject: [PATCH 07/14] fix: use correct error for embed limit --- src/lib/messages/cache.ts | 8 ++++++-- src/lib/messages/edit.ts | 4 ++-- src/lib/messages/send.ts | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lib/messages/cache.ts b/src/lib/messages/cache.ts index d0d4aa4..9d3bd08 100644 --- a/src/lib/messages/cache.ts +++ b/src/lib/messages/cache.ts @@ -3,7 +3,11 @@ import { Snowflake } from "discord-api-types/globals"; import { FastifyInstance } from "fastify"; -import { ExpectedFailure, InteractionOrRequestFinalStatus } from "../../errors"; +import { + ExpectedFailure, + InteractionOrRequestFinalStatus, + LimitHit, +} from "../../errors"; import { checkEmbedMeetsLimits } from "./embeds/checks"; import { StoredEmbed } from "./embeds/types"; @@ -42,7 +46,7 @@ const saveMessageToCache = ({ if (data.embed !== undefined) { const exceedsLimits = checkEmbedMeetsLimits(data.embed); if (exceedsLimits) { - throw new ExpectedFailure( + throw new LimitHit( InteractionOrRequestFinalStatus.EMBED_EXCEEDS_DISCORD_LIMITS, "The embed exceeds one or more of limits on embeds." ); diff --git a/src/lib/messages/edit.ts b/src/lib/messages/edit.ts index 9b7ff1a..98b1158 100644 --- a/src/lib/messages/edit.ts +++ b/src/lib/messages/edit.ts @@ -13,9 +13,9 @@ import { FastifyInstance } from "fastify"; import { embedPink } from "../../constants"; import { parseDiscordPermissionValuesToStringNames } from "../../consts"; import { - ExpectedFailure, ExpectedPermissionFailure, InteractionOrRequestFinalStatus, + LimitHit, UnexpectedFailure, } from "../../errors"; import limits from "../../limits"; @@ -127,7 +127,7 @@ async function editMessage({ if (embed !== undefined) { const exceedsLimits = checkEmbedMeetsLimits(embed); if (exceedsLimits) { - throw new ExpectedFailure( + throw new LimitHit( InteractionOrRequestFinalStatus.EMBED_EXCEEDS_DISCORD_LIMITS, "The embed exceeds one or more of limits on embeds." ); diff --git a/src/lib/messages/send.ts b/src/lib/messages/send.ts index 6418fd9..2f39bbe 100644 --- a/src/lib/messages/send.ts +++ b/src/lib/messages/send.ts @@ -15,6 +15,7 @@ import { ExpectedFailure, ExpectedPermissionFailure, InteractionOrRequestFinalStatus, + LimitHit, UnexpectedFailure, } from "../../errors"; import { InternalPermissions } from "../permissions/consts"; @@ -147,7 +148,7 @@ async function sendMessage({ if (embed !== undefined) { const exceedsLimits = checkEmbedMeetsLimits(embed); if (exceedsLimits) { - throw new ExpectedFailure( + throw new LimitHit( InteractionOrRequestFinalStatus.EMBED_EXCEEDS_DISCORD_LIMITS, "The embed exceeds one or more of limits on embeds." ); From 72ca6b1a208539d98ba9e81bbe731f333fc63761 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Tue, 28 Jun 2022 09:25:07 +1200 Subject: [PATCH 08/14] feat: check field length when adding embed field --- src/interactions/buttons/message-generation.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/interactions/buttons/message-generation.ts b/src/interactions/buttons/message-generation.ts index 2dd9c7a..71cf80b 100644 --- a/src/interactions/buttons/message-generation.ts +++ b/src/interactions/buttons/message-generation.ts @@ -10,6 +10,7 @@ import { FastifyInstance } from "fastify"; import { embedPink } from "../../constants"; import { InteractionOrRequestFinalStatus, + LimitHit, UnexpectedFailure, } from "../../errors"; import { @@ -186,6 +187,14 @@ export default async function handleMessageGenerationButton( ], }); case "embed-add-field": + // Check if there are 25+ fields - if so cannot add any more + + if ((currentStatus.embed?.fields?.length ?? 0) >= 25) { + throw new LimitHit( + InteractionOrRequestFinalStatus.EMBED_EXCEEDS_DISCORD_LIMITS, + "Only 25 fields allowed in an embed." + ); + } return createModal({ title: "Add Embed Field", custom_id: interaction.data.custom_id, // This is the same From 11c0f17118fb7a5c37f23e2e50743b6ea9dfe858 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Tue, 28 Jun 2022 09:28:08 +1200 Subject: [PATCH 09/14] feat: check for title and description when editing / adding an embed --- src/errors.ts | 1 + src/lib/messages/edit.ts | 9 +++++++++ src/lib/messages/send.ts | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/src/errors.ts b/src/errors.ts index 6f51880..e26c1a9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -32,6 +32,7 @@ enum InteractionOrRequestFinalStatus { ATTEMPTING_TO_SEND_WHEN_NO_CONTENT_SET, EMBED_VALUE_EDITING_MALFORMED, EMBED_EDITING_MISSING_REQUIRED_VALUE, + EMBED_REQUIRES_TITLE_OR_DESCRIPTION, GENERIC_EXPECTED_PERMISSIONS_FAILURE = 3000, USER_MISSING_DISCORD_PERMISSION, BOT_MISSING_DISCORD_PERMISSION, diff --git a/src/lib/messages/edit.ts b/src/lib/messages/edit.ts index 98b1158..cf669c0 100644 --- a/src/lib/messages/edit.ts +++ b/src/lib/messages/edit.ts @@ -13,6 +13,7 @@ import { FastifyInstance } from "fastify"; import { embedPink } from "../../constants"; import { parseDiscordPermissionValuesToStringNames } from "../../consts"; import { + ExpectedFailure, ExpectedPermissionFailure, InteractionOrRequestFinalStatus, LimitHit, @@ -132,6 +133,14 @@ async function editMessage({ "The embed exceeds one or more of limits on embeds." ); } + + // Also check if title and / or description is set on the embed + if (embed.title === undefined && embed.description === undefined) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_REQUIRES_TITLE_OR_DESCRIPTION, + "The embed requires a title or description." + ); + } } const embeds: APIEmbed[] = []; if (embed) { diff --git a/src/lib/messages/send.ts b/src/lib/messages/send.ts index 2f39bbe..a2a5226 100644 --- a/src/lib/messages/send.ts +++ b/src/lib/messages/send.ts @@ -153,6 +153,13 @@ async function sendMessage({ "The embed exceeds one or more of limits on embeds." ); } + // Also check if title and / or description is set on the embed + if (embed.title === undefined && embed.description === undefined) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.EMBED_REQUIRES_TITLE_OR_DESCRIPTION, + "The embed requires a title or description." + ); + } } const embeds: APIEmbed[] = []; if (embed) { From 82cbcc1518549d1188b1163e6cd7eb6c42fb5063 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Tue, 28 Jun 2022 09:28:57 +1200 Subject: [PATCH 10/14] fix(style): unused import --- src/lib/messages/cache.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/messages/cache.ts b/src/lib/messages/cache.ts index 9d3bd08..cde735a 100644 --- a/src/lib/messages/cache.ts +++ b/src/lib/messages/cache.ts @@ -3,11 +3,7 @@ import { Snowflake } from "discord-api-types/globals"; import { FastifyInstance } from "fastify"; -import { - ExpectedFailure, - InteractionOrRequestFinalStatus, - LimitHit, -} from "../../errors"; +import { InteractionOrRequestFinalStatus, LimitHit } from "../../errors"; import { checkEmbedMeetsLimits } from "./embeds/checks"; import { StoredEmbed } from "./embeds/types"; From 84b72fb5571e75d514182eb97e5dc67d2cb2c883 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Wed, 29 Jun 2022 12:14:39 +1200 Subject: [PATCH 11/14] feat: add embed options to editing Make content nullable in prisma schema Generate embed from existing embed on edit button Make embed field a modal paragraph - the length for a short isn't checked Add edit action handling for message generation Store messageId in cache Adjust cache handling - no key throws an error - and add TTL of one day Adjust message generation for message generation - add edit type and message link Adjust handling for missing content in log embeds Include embed in return for checkEditPossible to allow embed caching for message generation --- .../migration.sql | 2 + prisma/schema.prisma | 2 +- src/errors.ts | 3 + src/interactions/buttons/delete.ts | 2 +- src/interactions/buttons/edit.ts | 102 ++++++++++++++---- .../buttons/message-generation.ts | 72 ++++++++++++- src/interactions/commands/chatInput/send.ts | 9 +- src/interactions/modals/message-generation.ts | 3 +- src/interactions/modals/report.ts | 2 +- .../selects/message-generation.ts | 2 +- src/interactions/shared/message-generation.ts | 38 +++++-- src/lib/messages/cache.ts | 20 ++-- src/lib/messages/delete.ts | 8 +- src/lib/messages/edit.ts | 47 ++++++-- src/lib/messages/send.ts | 8 +- src/plugins/redis.ts | 7 +- 16 files changed, 273 insertions(+), 54 deletions(-) create mode 100644 prisma/migrations/20220628235929_content_can_be_null/migration.sql diff --git a/prisma/migrations/20220628235929_content_can_be_null/migration.sql b/prisma/migrations/20220628235929_content_can_be_null/migration.sql new file mode 100644 index 0000000..57744f1 --- /dev/null +++ b/prisma/migrations/20220628235929_content_can_be_null/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Message" ALTER COLUMN "content" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b8ffe32..c84655b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,7 +35,7 @@ model Message { guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade, onUpdate: NoAction) channelId BigInt channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade, onUpdate: NoAction) - content String /// @encrypted + content String? /// @encrypted editedAt DateTime editedBy BigInt deleted Boolean @default(false) diff --git a/src/errors.ts b/src/errors.ts index e26c1a9..024407e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -33,6 +33,7 @@ enum InteractionOrRequestFinalStatus { EMBED_VALUE_EDITING_MALFORMED, EMBED_EDITING_MISSING_REQUIRED_VALUE, EMBED_REQUIRES_TITLE_OR_DESCRIPTION, + MESSAGE_GENERATION_CACHE_NOT_FOUND, GENERIC_EXPECTED_PERMISSIONS_FAILURE = 3000, USER_MISSING_DISCORD_PERMISSION, BOT_MISSING_DISCORD_PERMISSION, @@ -72,6 +73,8 @@ enum InteractionOrRequestFinalStatus { MESSAGE_NOT_FOUND_IN_DATABASE_AFTER_CHECKS_DONE, FIELD_SELECT_OUT_OF_INDEX, EMBED_EDITING_MISSING_DISCORD_REQUIRED_VALUE, + MESSAGE_ID_MISSING_ON_MESSAGE_EDIT_CACHE, + INTERACTION_TIMED_OUT_HTTP, } class CustomError extends Error { diff --git a/src/interactions/buttons/delete.ts b/src/interactions/buttons/delete.ts index 5f4b7e9..e8cab89 100644 --- a/src/interactions/buttons/delete.ts +++ b/src/interactions/buttons/delete.ts @@ -44,7 +44,7 @@ export default async function handleDeleteButton( title: "Delete Message", url: `https://discord.com/channels/${interaction.guild_id}/${databaseMessage.channelId}/${messageId}`, description: `Are you sure you want to delete this message?\n**Content:**\n\n${ - content.length > maxLength + content !== null && content.length > maxLength ? `${content.substring(0, maxLength)}...` : content }`, diff --git a/src/interactions/buttons/edit.ts b/src/interactions/buttons/edit.ts index 63a2bcb..d6bdd32 100644 --- a/src/interactions/buttons/edit.ts +++ b/src/interactions/buttons/edit.ts @@ -1,17 +1,26 @@ -import { APIMessageComponentGuildInteraction } from "discord-api-types/v9"; +import { + APIEmbedAuthor, + APIEmbedFooter, + APIMessageComponentGuildInteraction, + InteractionResponseType, + MessageFlags, +} from "discord-api-types/v9"; import { FastifyInstance } from "fastify"; import { InteractionOrRequestFinalStatus, UnexpectedFailure, } from "../../errors"; +import { + MessageSavedInCache, + saveMessageToCache, +} from "../../lib/messages/cache"; +import { createMessageCacheKey } from "../../lib/messages/cache"; import { checkEditPossible } from "../../lib/messages/edit"; +import { StoredEmbed } from "../../lib/messages/embeds/types"; import { GuildSession } from "../../lib/session"; import { InternalInteractionType } from "../interaction"; -import { - createModal, - createTextInputWithRow, -} from "../modals/createStructures"; +import { createInitialMessageGenerationEmbed } from "../shared/message-generation"; import { InteractionReturnData } from "../types"; export default async function handleEditButton( @@ -33,19 +42,74 @@ export default async function handleEditButton( instance, messageId, }); - return createModal({ - title: "Edit Message", - custom_id: interaction.data.custom_id, // This is the same ( `edit${messageId}`) - components: [ - createTextInputWithRow({ - label: "Message Content", - value: databaseMessage.content, - max_length: 2000, - min_length: 1, - required: true, - custom_id: "content", - short: false, - }), - ], + + let embed: StoredEmbed | undefined = undefined; + if (databaseMessage?.embed !== null && databaseMessage?.embed !== undefined) { + let footer: APIEmbedFooter | undefined = undefined; + if (databaseMessage.embed.footerText !== null) { + footer = { + text: databaseMessage.embed.footerText, + icon_url: databaseMessage.embed.footerIconUrl ?? undefined, + }; + } + let author: APIEmbedAuthor | undefined = undefined; + if (databaseMessage.embed.authorName !== null) { + author = { + name: databaseMessage.embed.authorName, + url: databaseMessage.embed.authorUrl ?? undefined, + icon_url: databaseMessage.embed.authorIconUrl ?? undefined, + }; + } + + embed = { + title: databaseMessage.embed.title ?? undefined, + description: databaseMessage.embed.description ?? undefined, + url: databaseMessage.embed.url ?? undefined, + timestamp: databaseMessage.embed.timestamp?.toISOString() ?? undefined, + color: databaseMessage.embed.color ?? undefined, + footer: footer, + author: author, + fields: + databaseMessage.embed.fields.map((field) => ({ + name: field.name, + value: field.value, + inline: field.inline, + })) ?? undefined, + thumbnail: + databaseMessage.embed.thumbnailUrl !== null + ? { + url: databaseMessage.embed.thumbnailUrl, + } + : undefined, + }; + } + + const messageGenerationKey = createMessageCacheKey( + interaction.id, + interaction.channel_id // TODO: Check if this is ok + ); + const cacheData: MessageSavedInCache = { + content: databaseMessage.content ?? undefined, + embed, + messageId: messageId, + }; + await saveMessageToCache({ + key: messageGenerationKey, + data: cacheData, + instance, }); + const embedData = createInitialMessageGenerationEmbed( + messageGenerationKey, + cacheData, + interaction.guild_id + ); + + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [embedData.embed], + components: embedData.components, + flags: MessageFlags.Ephemeral, + }, + }; } diff --git a/src/interactions/buttons/message-generation.ts b/src/interactions/buttons/message-generation.ts index 71cf80b..35a4c75 100644 --- a/src/interactions/buttons/message-generation.ts +++ b/src/interactions/buttons/message-generation.ts @@ -18,6 +18,7 @@ import { MessageSavedInCache, splitMessageCacheKey, } from "../../lib/messages/cache"; +import { editMessage } from "../../lib/messages/edit"; import { sendMessage } from "../../lib/messages/send"; import { GuildSession } from "../../lib/session"; import { addTipToEmbed } from "../../lib/tips"; @@ -206,7 +207,7 @@ export default async function handleMessageGenerationButton( required: true, custom_id: "name", placeholder: "Field name", - short: true, + short: false, }), createTextInputWithRow({ label: "Embed Field Value", @@ -264,7 +265,8 @@ export default async function handleMessageGenerationButton( case "embed-back": returnData = createInitialMessageGenerationEmbed( messageGenerationKey, - currentStatus + currentStatus, + interaction.guild_id ); return { type: InteractionResponseType.UpdateMessage, @@ -285,6 +287,16 @@ export default async function handleMessageGenerationButton( messageGenerationKey, }); + case "edit": + return await handleEdit({ + channelId, + currentStatus, + instance, + session, + interaction, + messageGenerationKey, + }); + case "select-fields": return await handleMessageGenerationSelect( internalInteraction, @@ -344,3 +356,59 @@ const handleSend = async ({ }, }; }; + +const handleEdit = async ({ + channelId, + currentStatus, + + instance, + + session, + interaction, + messageGenerationKey, +}: { + channelId: string; + currentStatus: MessageSavedInCache; + instance: FastifyInstance; + + session: GuildSession; + interaction: APIMessageComponentGuildInteraction; + messageGenerationKey: string; +}): Promise => { + if (currentStatus.messageId === undefined) { + throw new UnexpectedFailure( + InteractionOrRequestFinalStatus.MESSAGE_ID_MISSING_ON_MESSAGE_EDIT_CACHE, + "Message ID missing on message edit cache" + ); + } + + await editMessage({ + channelId, + messageId: currentStatus.messageId, + content: currentStatus.content, + embed: currentStatus.embed, + instance, + session, + }); + + await instance.redisCache.deleteMessageCache(messageGenerationKey); + + const messageLink = `https://discord.com/channels/${interaction.guild_id}/${channelId}/${currentStatus.messageId}`; + + const embed: APIEmbed = { + color: embedPink, + title: "Message Edited", + description: `Message edited! [Jump to message](${messageLink})`, + url: messageLink, + timestamp: new Date().toISOString(), + }; + + return { + type: InteractionResponseType.UpdateMessage, + data: { + embeds: [addTipToEmbed(embed)], + components: [], + flags: MessageFlags.Ephemeral, + }, + }; +}; diff --git a/src/interactions/commands/chatInput/send.ts b/src/interactions/commands/chatInput/send.ts index 3905aac..4767718 100644 --- a/src/interactions/commands/chatInput/send.ts +++ b/src/interactions/commands/chatInput/send.ts @@ -13,7 +13,10 @@ import { InteractionOrRequestFinalStatus, UnexpectedFailure, } from "../../../errors"; -import { createMessageCacheKey } from "../../../lib/messages/cache"; +import { + createMessageCacheKey, + saveMessageToCache, +} from "../../../lib/messages/cache"; import { checkSendMessagePossible, ThreadOptionObject, @@ -101,9 +104,11 @@ export default async function handleSendCommand( }); } const messageGenerationKey = createMessageCacheKey(interaction.id, channelId); + await saveMessageToCache({ key: messageGenerationKey, data: {}, instance }); // Otherwise it'll return null when fetching and throw an error. const embedData = createInitialMessageGenerationEmbed( messageGenerationKey, - {} // Empty as this is the start of the process + {}, // Empty as this is the start of the process, + interaction.guild_id ); return { diff --git a/src/interactions/modals/message-generation.ts b/src/interactions/modals/message-generation.ts index a204528..73627aa 100644 --- a/src/interactions/modals/message-generation.ts +++ b/src/interactions/modals/message-generation.ts @@ -145,7 +145,8 @@ const handleContent = async ({ const responseData = createInitialMessageGenerationEmbed( messageGenerationKey, - currentStatus + currentStatus, + interaction.guild_id ); return { diff --git a/src/interactions/modals/report.ts b/src/interactions/modals/report.ts index 6986a27..5892b70 100644 --- a/src/interactions/modals/report.ts +++ b/src/interactions/modals/report.ts @@ -57,7 +57,7 @@ export default async function handleModalReport( await instance.prisma.report.create({ data: { userId: BigInt(interaction.member.user.id), - content: storedMessage.content, + content: storedMessage.content ?? "", reportedAt: new Date(), guildId: storedMessage.guildId, channelId: storedMessage.channelId, diff --git a/src/interactions/selects/message-generation.ts b/src/interactions/selects/message-generation.ts index 707fafb..d19474d 100644 --- a/src/interactions/selects/message-generation.ts +++ b/src/interactions/selects/message-generation.ts @@ -72,7 +72,7 @@ export default async function handleMessageGenerationSelect( required: false, custom_id: "name", placeholder: "Field name", - short: true, + short: false, }), createTextInputWithRow({ label: "Embed Field Value", diff --git a/src/interactions/shared/message-generation.ts b/src/interactions/shared/message-generation.ts index 9ec7bf6..7eeaaa9 100644 --- a/src/interactions/shared/message-generation.ts +++ b/src/interactions/shared/message-generation.ts @@ -1,3 +1,4 @@ +import { Snowflake } from "discord-api-types/globals"; import { APIActionRowComponent, APIEmbed, @@ -8,13 +9,17 @@ import { } from "discord-api-types/v9"; import { embedPink } from "../../constants"; -import { MessageSavedInCache } from "../../lib/messages/cache"; +import { + MessageSavedInCache, + splitMessageCacheKey, +} from "../../lib/messages/cache"; import { addTipToEmbed } from "../../lib/tips"; type MessageGenerationButtonTypes = | "content" | "embed" | "send" + | "edit" | "embed-metadata" | "select-fields" | "embed-footer" @@ -39,20 +44,36 @@ interface CreateMessageGenerationEmbedResult { } const createInitialMessageGenerationEmbed = ( messageGenerationKey: string, - currentStatus: MessageSavedInCache + currentStatus: MessageSavedInCache, + guildId: Snowflake ): CreateMessageGenerationEmbedResult => { + const type = currentStatus.messageId === undefined ? "send" : "edit"; + const channelId = splitMessageCacheKey(messageGenerationKey).channelId; + const messageLink = `https://discord.com/channels/${guildId}/${channelId}/${ + currentStatus.messageId ?? "" + }`; return { embed: addTipToEmbed({ - title: "Message Generation Flow", + title: `Message Generation Flow - ${ + type[0].toUpperCase() + type.slice(1) + }`, description: - "Use the buttons below to update the state of the message and embed. When you are done, click the send button." + - "\n\n" + - `**Current Content**: ${currentStatus.content ?? ""}` + + `Use the buttons below to update the state of the message and embed. When you are done, click the ${type} button.` + "\n\n" + + `${ + currentStatus.content !== undefined + ? `**Current Content**: ${currentStatus.content ?? ""}` + : "" + }\n\n` + `${ currentStatus.embed !== undefined ? "Embed exists, click the edit embed button to edit it." : "" + }\n\n` + + `${ + currentStatus.messageId !== undefined + ? `[Jump to message being edited](${messageLink})` + : "" }`, color: embedPink, }), @@ -81,11 +102,11 @@ const createInitialMessageGenerationEmbed = ( { type: ComponentType.Button, - label: "Send", + label: type[0].toUpperCase() + type.slice(1), style: ButtonStyle.Success, custom_id: generateMessageGenerationCustomId( messageGenerationKey, - "send" + type ), }, ], @@ -116,6 +137,7 @@ const createEmbedMessageGenerationEmbed = ( max_values: 1, min_values: 1, options: currentStatus.embed.fields.map((field, index) => { + console.log(field); return { label: field.name, value: index.toString(), diff --git a/src/lib/messages/cache.ts b/src/lib/messages/cache.ts index cde735a..4d6ce61 100644 --- a/src/lib/messages/cache.ts +++ b/src/lib/messages/cache.ts @@ -3,7 +3,11 @@ import { Snowflake } from "discord-api-types/globals"; import { FastifyInstance } from "fastify"; -import { InteractionOrRequestFinalStatus, LimitHit } from "../../errors"; +import { + ExpectedFailure, + InteractionOrRequestFinalStatus, + LimitHit, +} from "../../errors"; import { checkEmbedMeetsLimits } from "./embeds/checks"; import { StoredEmbed } from "./embeds/types"; @@ -16,7 +20,10 @@ const createMessageCacheKey = ( const splitMessageCacheKey = ( key: string -): { interactionId: Snowflake; channelId: Snowflake } => { +): { + interactionId: Snowflake; + channelId: Snowflake; +} => { const [interactionId, channelId] = key.split("-"); return { interactionId, @@ -27,6 +34,7 @@ const splitMessageCacheKey = ( interface MessageSavedInCache { content?: string; embed?: StoredEmbed; + messageId?: Snowflake; } const saveMessageToCache = ({ @@ -60,10 +68,10 @@ const getMessageFromCache = async ({ }): Promise => { const message = await instance.redisCache.getMessageCache(key); if (message === null) { - return { - embed: undefined, - content: undefined, - }; + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.MESSAGE_GENERATION_CACHE_NOT_FOUND, + "The cache for this message generation was not found. This could be due to a timeout - or a restart. \nPlease try the initial action again, and if this error persists, contact support." + ); } return message; }; diff --git a/src/lib/messages/delete.ts b/src/lib/messages/delete.ts index 2c88037..fc7275a 100644 --- a/src/lib/messages/delete.ts +++ b/src/lib/messages/delete.ts @@ -165,7 +165,13 @@ async function deleteMessage({ title: "Message Deleted", description: `Message (${messageId}) deleted` + - `\n\n**Message Content:**\n${messageBefore.content}`, + `${ + messageBefore.content !== null && messageBefore.content !== "" + ? `\n\n**Message Content:**\n${messageBefore.content}` + : messageBefore.content === "" + ? "\n**Message Content was empty**" + : "" + }`, fields: [ { name: "Action By:", value: `<@${session.userId}>`, inline: true }, { name: "Channel:", value: `<#${channelId}>`, inline: true }, diff --git a/src/lib/messages/edit.ts b/src/lib/messages/edit.ts index cf669c0..a04b60d 100644 --- a/src/lib/messages/edit.ts +++ b/src/lib/messages/edit.ts @@ -1,5 +1,5 @@ import { DiscordAPIError, RawFile } from "@discordjs/rest"; -import { Message, Prisma } from "@prisma/client"; +import { EmbedField, Message, MessageEmbed, Prisma } from "@prisma/client"; import { APIEmbed, APIEmbedAuthor, @@ -50,7 +50,15 @@ const checkEditPossible = async ({ instance, messageId, session, -}: CheckEditPossibleOptions): Promise => { +}: CheckEditPossibleOptions): Promise< + Message & { + embed: + | (MessageEmbed & { + fields: EmbedField[]; + }) + | null; + } +> => { const userHasRequiredDiscordPermissions = await session.hasDiscordPermissions( requiredPermissionsEdit, channelId @@ -84,6 +92,13 @@ const checkEditPossible = async ({ const databaseMessage = await instance.prisma.message.findFirst({ where: { id: BigInt(messageId) }, + include: { + embed: { + include: { + fields: true, + }, + }, + }, orderBy: { editedAt: "desc" }, }); if (!checkDatabaseMessage(databaseMessage)) { @@ -110,7 +125,7 @@ const checkEditPossible = async ({ }; interface EditMessageOptions extends CheckEditPossibleOptions { - content: string; + content?: string; embed?: StoredEmbed; } @@ -125,6 +140,12 @@ async function editMessage({ await checkEditPossible({ channelId, instance, messageId, session }); try { // Check if embed exceeds limits + if ((content === undefined || content === "") && embed === undefined) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.ATTEMPTING_TO_SEND_WHEN_NO_CONTENT_SET, + "No content or embeds have been set, this is required to send a message" + ); + } if (embed !== undefined) { const exceedsLimits = checkEmbedMeetsLimits(embed); if (exceedsLimits) { @@ -271,10 +292,24 @@ async function editMessage({ title: "Message Edited", description: `Message (${messageId}) edited` + - `\n**Original Content:**\n${ - messageBefore?.content ?? "" //This should never be null as the message is being edited + `${ + messageBefore !== null && + messageBefore.content !== null && + messageBefore.content !== "" + ? `\n**Original Content:**\n${messageBefore.content}` + : messageBefore?.content === "" + ? "\n**Original Content was empty**" + : "" }` + - `\n**New Content:**\n${response.content}`, + `${ + response.content !== undefined && + response.content !== null && + response.content !== "" + ? `\n**New Content:**\n${response.content}` + : response.content === "" + ? "\n**New Content is empty**" + : "" + }`, fields: [ { name: "Action By:", value: `<@${session.userId}>`, inline: true }, { name: "Channel:", value: `<#${channelId}>`, inline: true }, diff --git a/src/lib/messages/send.ts b/src/lib/messages/send.ts index a2a5226..7917033 100644 --- a/src/lib/messages/send.ts +++ b/src/lib/messages/send.ts @@ -251,7 +251,13 @@ async function sendMessage({ title: "Message Sent", description: `Message (${messageResult.id}) sent` + - `\n**Content:**\n${messageResult.content}`, + `${ + messageResult.content !== undefined && + messageResult.content !== "" && + messageResult.content !== null + ? `\n**Content:**\n${messageResult.content}` + : "" + }`, fields: [ { name: "Action By:", value: `<@${session.userId}>`, inline: true }, { name: "Channel:", value: `<#${channelId}>`, inline: true }, diff --git a/src/plugins/redis.ts b/src/plugins/redis.ts index 7e9c1f8..44a1000 100644 --- a/src/plugins/redis.ts +++ b/src/plugins/redis.ts @@ -101,10 +101,9 @@ class RedisCache { message: MessageSavedInCache ): Promise { key = `message:${key}`; - await this._set({ - key, - value: JSON.stringify(message), - }); + // TTL of one day + await this._set({ key, value: JSON.stringify(message) }); + await this._setExpiry({ key, expiry: 1000 * 60 * 60 * 24 }); } async getMessageCache(key: string): Promise { const data = await this._get({ key: `message:${key}` }); From 0759ae879f234c107f8b2b56b6a28a1a63dcfbd4 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Wed, 29 Jun 2022 17:17:37 +1200 Subject: [PATCH 12/14] feat: add embeds to adding messages --- src/errors.ts | 1 + .../commands/message/addMessage.ts | 53 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 024407e..ee67019 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -34,6 +34,7 @@ enum InteractionOrRequestFinalStatus { EMBED_EDITING_MISSING_REQUIRED_VALUE, EMBED_REQUIRES_TITLE_OR_DESCRIPTION, MESSAGE_GENERATION_CACHE_NOT_FOUND, + MIGRATION_ATTEMPTED_ON_MESSAGE_WITH_MULTIPLE_EMBEDS, GENERIC_EXPECTED_PERMISSIONS_FAILURE = 3000, USER_MISSING_DISCORD_PERMISSION, BOT_MISSING_DISCORD_PERMISSION, diff --git a/src/interactions/commands/message/addMessage.ts b/src/interactions/commands/message/addMessage.ts index 5e8ed73..2cf8d53 100644 --- a/src/interactions/commands/message/addMessage.ts +++ b/src/interactions/commands/message/addMessage.ts @@ -1,4 +1,6 @@ +import { Prisma } from "@prisma/client"; import { + APIEmbed, APIMessage, APIMessageApplicationCommandGuildInteraction, InteractionResponseType, @@ -69,12 +71,60 @@ export default async function handleAddMessageCommand( "Message already added to the database. This command is just for migrating messages to the new system." ); } + // Only allow one embed + if (message.embeds.length > 1) { + throw new ExpectedFailure( + InteractionOrRequestFinalStatus.MIGRATION_ATTEMPTED_ON_MESSAGE_WITH_MULTIPLE_EMBEDS, + "Message must have only one embed to be added!" + ); + } + const embed: APIEmbed | undefined = message.embeds[0]; await checkSendMessagePossible({ channelId: message.channel_id, instance, session, }); + let embedQuery: + | Prisma.MessageEmbedCreateNestedOneWithoutMessageInput + | undefined = undefined; + if (embed !== null) { + let fieldQuery: + | Prisma.EmbedFieldCreateNestedManyWithoutEmbedInput + | undefined; + + if (embed.fields && embed.fields.length > 0) { + fieldQuery = { + create: embed.fields.map((field) => ({ + name: field.name, + value: field.value, + inline: field.inline, + })), + }; + } + let timestamp: Date | undefined; + if (embed.timestamp !== undefined) { + timestamp = new Date(embed.timestamp); + } + + embedQuery = { + create: { + title: embed.title, + description: embed.description, + url: embed.url, + timestamp, + color: embed.color, + footerText: embed.footer?.text, + footerIconUrl: embed.footer?.icon_url, + authorName: embed.author?.name, + authorIconUrl: embed.author?.icon_url, + authorUrl: embed.author?.url, + thumbnailUrl: embed.thumbnail?.url, + fields: fieldQuery, + }, + }; + } + // User should be able to send messages in the channel to add the message // Add the message to the database await instance.prisma.message.create({ @@ -108,6 +158,7 @@ export default async function handleAddMessageCommand( }, }, }, + embed: embedQuery, }, }); @@ -121,7 +172,7 @@ export default async function handleAddMessageCommand( addTipToEmbed({ title: "Message Added", color: embedPink, - description: `Message added! You can now perform all usual actions on that [message](${messageLink}).`, + description: `Message added! You can now perform all usual actions on this [message](${messageLink}).`, timestamp: new Date().toISOString(), url: messageLink, }), From 61439eee308e8d424692e3da2948c6c98b28e463 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Wed, 29 Jun 2022 17:29:10 +1200 Subject: [PATCH 13/14] chore: add to todo --- todo.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/todo.md b/todo.md index 800c71a..f78ceaa 100644 --- a/todo.md +++ b/todo.md @@ -40,3 +40,8 @@ // Move permission checks for permissions to the logic // Add permission presets // Add permission tag to /info cmd + +\\\\\ + +DEFER if adding command on actions +DEFER ALL COMMANDS THAT MAKE API REQUESTS From 6ac68bfb9796433d0b4997997ef23c83332b5ce2 Mon Sep 17 00:00:00 2001 From: AnotherCat Date: Wed, 29 Jun 2022 17:30:16 +1200 Subject: [PATCH 14/14] chore: remove unneeded comment --- src/interactions/selects/manage-permissions-select.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/interactions/selects/manage-permissions-select.ts b/src/interactions/selects/manage-permissions-select.ts index d3e6c01..a994bdb 100644 --- a/src/interactions/selects/manage-permissions-select.ts +++ b/src/interactions/selects/manage-permissions-select.ts @@ -113,7 +113,6 @@ export default async function handleManagePermissionsSelect( messageId: interaction.message.id, }); } - // TODO Allow } else { // Action is allow const permissionsToAllow: number[] = [];