diff --git a/backend/package-lock.json b/backend/package-lock.json index 6d393318..482431a1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -89,6 +89,11 @@ "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", "dev": true }, + "@sqltools/formatter": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.2.tgz", + "integrity": "sha512-/5O7Fq6Vnv8L6ucmPjaWbVG1XkP4FO+w5glqfkIsq3Xw4oyNAdJddbnYodNDAfjVUvo/rrSCTom4kAND7T1o5Q==" + }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", @@ -359,12 +364,14 @@ "ansi-regex": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true }, "ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -385,9 +392,9 @@ } }, "app-root-path": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-2.2.1.tgz", - "integrity": "sha512-91IFKeKk7FjfmezPKkwtaRvSpnUc4gDwPAjA1YZ9Gn0q0PPeW+vbeUsZuyDwjI7+QTHhcLen2v25fi/AmhvbJA==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.0.0.tgz", + "integrity": "sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw==" }, "aproba": { "version": "1.2.0", @@ -726,9 +733,9 @@ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "base64-js": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", - "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "base64url": { "version": "3.0.1", @@ -922,12 +929,12 @@ } }, "buffer": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", - "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, "buffer-from": { @@ -990,12 +997,14 @@ "camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -1069,15 +1078,61 @@ } }, "cli-highlight": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.1.tgz", - "integrity": "sha512-0y0VlNmdD99GXZHYnvrQcmHxP8Bi6T00qucGgBgGv4kJ0RyDthNnnFPupHV7PYv/OXSVk+azFbOeaW6+vGmx9A==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.10.tgz", + "integrity": "sha512-CcPFD3JwdQ2oSzy+AMG6j3LRTkNjM82kzcSKzoVw6cLanDCJNlsLjeqVTOTfOfucnWv5F0rmBemVf1m9JiIasw==", "requires": { - "chalk": "^2.3.0", - "highlight.js": "^9.6.0", + "chalk": "^4.0.0", + "highlight.js": "^10.0.0", "mz": "^2.4.0", - "parse5": "^4.0.0", - "yargs": "^13.0.0" + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } } }, "cli-spinners": { @@ -1131,13 +1186,43 @@ } }, "cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "requires": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "requires": { + "ansi-regex": "^5.0.0" + } + } } }, "clone": { @@ -1364,11 +1449,6 @@ "ms": "2.0.0" } }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" - }, "decompress-response": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", @@ -1403,14 +1483,6 @@ "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", "dev": true }, - "define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", - "requires": { - "object-keys": "^1.0.12" - } - }, "del": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/del/-/del-5.1.0.tgz", @@ -1587,32 +1659,10 @@ "is-arrayish": "^0.2.1" } }, - "es-abstract": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.0.tgz", - "integrity": "sha512-xdQnfykZ9JMEiasTAJZJdMWCQ1Vm00NBw79/AWi7ELfZuuPCSOMDZbT9mkOfSctVtfhb+sAAzrm+j//GjjLHLg==", - "requires": { - "es-to-primitive": "^1.2.0", - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.0", - "is-callable": "^1.1.4", - "is-regex": "^1.0.4", - "object-inspect": "^1.6.0", - "object-keys": "^1.1.1", - "string.prototype.trimleft": "^2.1.0", - "string.prototype.trimright": "^2.1.0" - } - }, - "es-to-primitive": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", - "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" }, "escape-goat": { "version": "2.1.1", @@ -1733,9 +1783,9 @@ } }, "figlet": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.2.4.tgz", - "integrity": "sha512-mv8YA9RruB4C5QawPaD29rEVx3N97ZTyNrE4DAfbhuo6tpcMdKnPVo8MlyT3RP5uPcg5M14bEJBq7kjFf4kAWg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.5.0.tgz", + "integrity": "sha512-ZQJM4aifMpz6H19AW1VqvZ7l4pOE9p7i/3LyxgO2kp+PO/VcDYNqIHEMtkccqIhTXMKci4kjueJr/iCQEaT/Ww==" }, "figures": { "version": "3.2.0", @@ -1848,11 +1898,6 @@ "dev": true, "optional": true }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, "gauge": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", @@ -1924,6 +1969,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.5.tgz", "integrity": "sha512-J9dlskqUXK1OeTOYBEn5s8aMukWMwWfs+rPTn/jn50Ux4MNXVhubL1wu/j2t+H4NVI+cXEcCaYellqaPVGXNqQ==", + "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -1989,14 +2035,6 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==" }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, "has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", @@ -2015,12 +2053,8 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" - }, - "has-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", - "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=" + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true }, "has-unicode": { "version": "2.0.1", @@ -2034,9 +2068,9 @@ "dev": true }, "highlight.js": { - "version": "9.16.2", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.16.2.tgz", - "integrity": "sha512-feMUrVLZvjy0oC7FVJQcSQRqbBq9kwqnYE4+Kj9ZjbHh3g+BisiPgF49NyQbVLNdrL/qqZr3Ca9yOKwgn2i/tw==" + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.6.0.tgz", + "integrity": "sha512-8mlRcn5vk/r4+QcqerapwBYTe+iPL5ih6xrNylxrnBdHQiijDETfXX7VIxC3UiCRiINBJfANBAsPzAvRQj8RpQ==" }, "hosted-git-info": { "version": "2.8.8", @@ -2076,9 +2110,9 @@ } }, "ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "ignore": { "version": "5.1.8", @@ -2170,11 +2204,6 @@ "binary-extensions": "^2.0.0" } }, - "is-callable": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", - "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==" - }, "is-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", @@ -2184,11 +2213,6 @@ "ci-info": "^2.0.0" } }, - "is-date-object": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", - "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=" - }, "is-error": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", @@ -2273,22 +2297,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true }, - "is-regex": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", - "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", - "requires": { - "has": "^1.0.1" - } - }, - "is-symbol": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", - "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", - "requires": { - "has-symbols": "^1.0.0" - } - }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -2371,12 +2379,27 @@ } }, "knub": { - "version": "30.0.0-beta.30", - "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.30.tgz", - "integrity": "sha512-K0aUlPVORy+p2XkZe6qYOT/ekjM43TN2TmzY76rIgkz7RtxpX5boSYj6dt4omLz6o7B5rCCBaNq9poEO+l2gfw==", + "version": "30.0.0-beta.35", + "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.35.tgz", + "integrity": "sha512-3fVefmp8hq8DxR8RuR/bqaFsXCkt2yf7NyotXaI8wq1FbPjM4/tM+YAzHIg9ER1L96KFZ9SDnRvs18DOb4Nk1w==", "requires": { - "knub-command-manager": "^8.1.2", + "knub-command-manager": "^9.1.0", "ts-essentials": "^6.0.7" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" + }, + "knub-command-manager": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/knub-command-manager/-/knub-command-manager-9.1.0.tgz", + "integrity": "sha512-pEtpWElbBoTRSL8kWSPRrTIuTIdvYGkP/wzOn77cieumC02adfwEt1Cc09HFvVT4ib35nf1y31oul36csaG7Vg==", + "requires": { + "escape-string-regexp": "^2.0.0" + } + } } }, "knub-command-manager": { @@ -2834,25 +2857,6 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, - "object-inspect": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", - "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==" - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" - }, - "object.getownpropertydescriptors": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", - "integrity": "sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY=", - "requires": { - "define-properties": "^1.1.2", - "es-abstract": "^1.5.1" - } - }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -3071,9 +3075,24 @@ "dev": true }, "parse5": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", - "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==" + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" + }, + "parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "requires": { + "parse5": "^6.0.1" + }, + "dependencies": { + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + } + } }, "parseurl": { "version": "1.3.3", @@ -3462,7 +3481,8 @@ "require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true }, "resolve": { "version": "1.17.0", @@ -3632,6 +3652,15 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "sharp": { "version": "0.23.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.23.4.tgz", @@ -3876,6 +3905,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, "requires": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", @@ -3885,28 +3915,11 @@ "emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true } } }, - "string.prototype.trimleft": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz", - "integrity": "sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==", - "requires": { - "define-properties": "^1.1.3", - "function-bind": "^1.1.1" - } - }, - "string.prototype.trimright": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz", - "integrity": "sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==", - "requires": { - "define-properties": "^1.1.3", - "function-bind": "^1.1.1" - } - }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -3919,6 +3932,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, "requires": { "ansi-regex": "^4.1.0" } @@ -3984,6 +3998,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -4056,9 +4071,9 @@ "dev": true }, "thenify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.0.tgz", - "integrity": "sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "requires": { "any-promise": "^1.0.0" } @@ -4180,9 +4195,9 @@ } }, "tslib": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", - "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "tunnel-agent": { "version": "0.6.0", @@ -4239,43 +4254,115 @@ } }, "typeorm": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.20.tgz", - "integrity": "sha512-VxB+9qH8D+PM19MIx18Zs3Fqv/ZINnnQvUGmBEiLYDrB9etdSdamgSTCIhWdFNndeJ6ldH4jbD0Z6HWsepMPlA==", + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.31.tgz", + "integrity": "sha512-dVvCEVHH48DG0QPXAKfo0l6ecQrl3A8ucGP4Yw4myz4YEDMProebTQo8as83uyES+nrwCbu3qdkL4ncC2+qcMA==", "requires": { - "app-root-path": "^2.0.1", - "buffer": "^5.1.0", - "chalk": "^2.4.2", - "cli-highlight": "^2.0.0", + "@sqltools/formatter": "1.2.2", + "app-root-path": "^3.0.0", + "buffer": "^5.5.0", + "chalk": "^4.1.0", + "cli-highlight": "^2.1.10", "debug": "^4.1.1", - "dotenv": "^6.2.0", - "glob": "^7.1.2", - "js-yaml": "^3.13.1", - "mkdirp": "^0.5.1", + "dotenv": "^8.2.0", + "glob": "^7.1.6", + "js-yaml": "^3.14.0", + "mkdirp": "^1.0.4", "reflect-metadata": "^0.1.13", - "tslib": "^1.9.0", - "xml2js": "^0.4.17", + "sha.js": "^2.4.11", + "tslib": "^1.13.0", + "xml2js": "^0.4.23", "yargonaut": "^1.1.2", - "yargs": "^13.2.1" + "yargs": "^16.0.3" }, "dependencies": { - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "requires": { - "ms": "^2.1.1" + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" } }, "dotenv": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.2.0.tgz", - "integrity": "sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } } } }, @@ -4396,15 +4483,6 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, - "util.promisify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz", - "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==", - "requires": { - "define-properties": "^1.1.2", - "object.getownpropertydescriptors": "^2.0.3" - } - }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -4456,7 +4534,8 @@ "which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true }, "which-pm-runs": { "version": "1.0.0", @@ -4539,13 +4618,64 @@ } }, "wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "requires": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "requires": { + "ansi-regex": "^5.0.0" + } + } } }, "wrappy": { @@ -4577,12 +4707,11 @@ "dev": true }, "xml2js": { - "version": "0.4.22", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.22.tgz", - "integrity": "sha512-MWTbxAQqclRSTnehWWe5nMKzI3VmJ8ltiJEco8akcC6j3miOhjjfzKum5sId+CWhfxdOs/1xauYr8/ZDBtQiRw==", + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", "requires": { "sax": ">=0.6.0", - "util.promisify": "~1.0.0", "xmlbuilder": "~11.0.0" } }, @@ -4603,7 +4732,8 @@ "y18n": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true }, "yallist": { "version": "2.1.2", @@ -4664,30 +4794,58 @@ } }, "yargs": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", - "integrity": "sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "requires": { - "cliui": "^5.0.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^13.1.1" + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "y18n": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", + "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==" + } } }, "yargs-parser": { - "version": "13.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", - "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } + "version": "20.2.5", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.5.tgz", + "integrity": "sha512-jYRGS3zWy20NtDtK2kBgo/TlAoy5YUuhD9/LZ7z7W4j1Fdw2cqD0xEEclf8fxc8xjD6X5Qr+qQQwCEsP8iRiYg==" }, "yawn-yaml": { "version": "github:dragory/yawn-yaml#77ab3870ca53c4693002c4a41336e7476e7934ed", diff --git a/backend/package.json b/backend/package.json index 281f6253..56e816d1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,10 +7,10 @@ "watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"", "watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"", "build": "rimraf dist && tsc", - "start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=127.0.0.1:9229 dist/backend/src/index.js", + "start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9229 dist/backend/src/index.js", "start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js", "watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"", - "start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=127.0.0.1:9239 dist/backend/src/api/index.js", + "start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9239 dist/backend/src/api/index.js", "start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js", "watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"", "typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js", @@ -39,7 +39,7 @@ "humanize-duration": "^3.15.0", "io-ts": "^2.0.0", "js-yaml": "^3.13.1", - "knub": "^30.0.0-beta.30", + "knub": "^30.0.0-beta.35", "knub-command-manager": "^8.1.2", "last-commit-log": "^2.1.0", "lodash.chunk": "^4.2.0", @@ -65,7 +65,7 @@ "tmp": "0.0.33", "tsconfig-paths": "^3.9.0", "twemoji": "^12.1.4", - "typeorm": "^0.2.14", + "typeorm": "^0.2.31", "uuid": "^3.3.2", "yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build", "zlib-sync": "^0.1.7" diff --git a/backend/src/RecoverablePluginError.ts b/backend/src/RecoverablePluginError.ts index 0a63ed78..3cb1c7ac 100644 --- a/backend/src/RecoverablePluginError.ts +++ b/backend/src/RecoverablePluginError.ts @@ -7,6 +7,8 @@ export enum ERRORS { NO_USER_NOTIFICATION_CHANNEL, INVALID_USER_NOTIFICATION_CHANNEL, INVALID_USER, + INVALID_MUTE_ROLE_ID, + MUTE_ROLE_ABOVE_ZEP, } export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = { @@ -16,6 +18,8 @@ export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = { [ERRORS.NO_USER_NOTIFICATION_CHANNEL]: "No user notify channel specified", [ERRORS.INVALID_USER_NOTIFICATION_CHANNEL]: "Invalid user notify channel specified", [ERRORS.INVALID_USER]: "Invalid user", + [ERRORS.INVALID_MUTE_ROLE_ID]: "Specified mute role is not invalid", + [ERRORS.MUTE_ROLE_ABOVE_ZEP]: "Specified mute role is above Zeppelin in the role hierarchy", }; export class RecoverablePluginError extends Error { diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index 5a38dd5f..1ef3091b 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -19,6 +19,8 @@ "MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin", + "MEMBER_TIMED_BAN": "🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", + "MEMBER_TIMED_UNBAN": "🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", "CHANNEL_CREATE": "🖊 Channel {channelMention(channel)} was created", "CHANNEL_DELETE": "🗑 Channel {channelMention(channel)} was deleted", @@ -38,6 +40,7 @@ "VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from **{oldChannel.name}** to **{newChannel.name}**", "VOICE_CHANNEL_LEAVE": "🎙 🔴 {userMention(member)} left **{channel.name}**", "VOICE_CHANNEL_FORCE_MOVE": "\uD83C\uDF99 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", + "VOICE_CHANNEL_FORCE_DISCONNECT": "\uD83C\uDF99 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", "COMMAND": "🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", @@ -49,6 +52,7 @@ "CASE_CREATE": "✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", "CASE_DELETE": "✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", + "MASSUNBAN": "⚒ {userMention(mod)} mass-unbanned {count} users", "MASSBAN": "⚒ {userMention(mod)} massbanned {count} users", "MASSMUTE": "📢🚫 {userMention(mod)} massmuted {count} users", diff --git a/backend/src/data/GuildCounters.ts b/backend/src/data/GuildCounters.ts new file mode 100644 index 00000000..3f05972e --- /dev/null +++ b/backend/src/data/GuildCounters.ts @@ -0,0 +1,501 @@ +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { getRepository, In, IsNull, LessThan, Not, Repository } from "typeorm"; +import { Counter } from "./entities/Counter"; +import { CounterValue } from "./entities/CounterValue"; +import { CounterTrigger, TRIGGER_COMPARISON_OPS, TriggerComparisonOp } from "./entities/CounterTrigger"; +import { CounterTriggerState } from "./entities/CounterTriggerState"; +import moment from "moment-timezone"; +import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils"; +import { connection } from "./db"; + +const comparisonStringRegex = new RegExp(`^(${TRIGGER_COMPARISON_OPS.join("|")})([1-9]\\d*)$`); + +/** + * @return Parsed comparison op and value, or null if the comparison string was invalid + */ +export function parseCondition(str: string): [TriggerComparisonOp, number] | null { + const matches = str.match(comparisonStringRegex); + return matches ? [matches[1] as TriggerComparisonOp, parseInt(matches[2], 10)] : null; +} + +export function buildConditionString(comparisonOp: TriggerComparisonOp, comparisonValue: number): string { + return `${comparisonOp}${comparisonValue}`; +} + +function isValidComparisonOp(op: string): boolean { + return TRIGGER_COMPARISON_OPS.includes(op as any); +} + +const REVERSE_OPS: Record = { + "=": "!=", + "!=": "=", + ">": "<=", + "<": ">=", + ">=": "<", + "<=": ">", +}; + +function getReverseComparisonOp(op: TriggerComparisonOp): TriggerComparisonOp { + return REVERSE_OPS[op]; +} + +const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS; +const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS; + +const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT + +async function deleteCountersMarkedToBeDeleted(): Promise { + await getRepository(Counter) + .createQueryBuilder() + .where("delete_at <= NOW()") + .delete() + .execute(); +} + +async function deleteTriggersMarkedToBeDeleted(): Promise { + await getRepository(CounterTrigger) + .createQueryBuilder() + .where("delete_at <= NOW()") + .delete() + .execute(); +} + +setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS); +setInterval(deleteTriggersMarkedToBeDeleted, 1 * HOURS); + +setTimeout(deleteCountersMarkedToBeDeleted, 1 * MINUTES); +setTimeout(deleteTriggersMarkedToBeDeleted, 1 * MINUTES); + +export class GuildCounters extends BaseGuildRepository { + private counters: Repository; + private counterValues: Repository; + private counterTriggers: Repository; + private counterTriggerStates: Repository; + + constructor(guildId) { + super(guildId); + this.counters = getRepository(Counter); + this.counterValues = getRepository(CounterValue); + this.counterTriggers = getRepository(CounterTrigger); + this.counterTriggerStates = getRepository(CounterTriggerState); + } + + async findOrCreateCounter(name: string, perChannel: boolean, perUser: boolean): Promise { + const existing = await this.counters.findOne({ + where: { + guild_id: this.guildId, + name, + }, + }); + + if (existing) { + // If the existing counter's properties match the ones we're looking for, return it. + // Otherwise, delete the existing counter and re-create it with the proper properties. + if (existing.per_channel === perChannel && existing.per_user === perUser) { + return existing; + } + + await this.counters.delete({ id: existing.id }); + } + + const insertResult = await this.counters.insert({ + guild_id: this.guildId, + name, + per_channel: perChannel, + per_user: perUser, + last_decay_at: moment.utc().format(DBDateFormat), + }); + + return (await this.counters.findOne({ + where: { + id: insertResult.identifiers[0].id, + }, + }))!; + } + + async markUnusedCountersToBeDeleted(idsToKeep: number[]): Promise { + if (idsToKeep.length === 0) { + return; + } + + const deleteAt = moment + .utc() + .add(DELETE_UNUSED_COUNTERS_AFTER, "ms") + .format(DBDateFormat); + await this.counters.update( + { + guild_id: this.guildId, + id: Not(In(idsToKeep)), + delete_at: IsNull(), + }, + { + delete_at: deleteAt, + }, + ); + } + + async deleteCountersMarkedToBeDeleted(): Promise { + await this.counters + .createQueryBuilder() + .where("delete_at <= NOW()") + .delete() + .execute(); + } + + async changeCounterValue(id: number, channelId: string | null, userId: string | null, change: number): Promise { + if (typeof change !== "number" || Number.isNaN(change) || !Number.isFinite(change)) { + throw new Error(`changeCounterValue() change argument must be a number`); + } + + channelId = channelId || "0"; + userId = userId || "0"; + + const rawUpdate = + change >= 0 ? `value = LEAST(value + ${change}, ${MAX_COUNTER_VALUE})` : `value = GREATEST(value ${change}, 0)`; + + await this.counterValues.query( + ` + INSERT INTO counter_values (counter_id, channel_id, user_id, value) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE ${rawUpdate} + `, + [id, channelId, userId, Math.max(change, 0)], + ); + } + + async setCounterValue(id: number, channelId: string | null, userId: string | null, value: number): Promise { + if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`setCounterValue() value argument must be a number`); + } + + channelId = channelId || "0"; + userId = userId || "0"; + + value = Math.max(value, 0); + + await this.counterValues.query( + ` + INSERT INTO counter_values (counter_id, channel_id, user_id, value) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE value = ? + `, + [id, channelId, userId, value, value], + ); + } + + async decay(id: number, decayPeriodMs: number, decayAmount: number) { + const counter = (await this.counters.findOne({ + where: { + id, + }, + }))!; + + const diffFromLastDecayMs = moment.utc().diff(moment.utc(counter.last_decay_at!), "ms"); + if (diffFromLastDecayMs < decayPeriodMs) { + return; + } + + const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount); + if (decayAmountToApply === 0) { + return; + } + + // Calculate new last_decay_at based on the rounded decay amount we applied. This makes it so that over time, the decayed amount will stay accurate, even if we round some here. + const newLastDecayDate = moment + .utc(counter.last_decay_at) + .add((decayAmountToApply / decayAmount) * decayPeriodMs, "ms") + .format(DBDateFormat); + + const rawUpdate = + decayAmountToApply >= 0 + ? `GREATEST(value - ${decayAmountToApply}, 0)` + : `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`; + + await this.counterValues.update( + { + counter_id: id, + }, + { + value: () => rawUpdate, + }, + ); + + await this.counters.update( + { + id, + }, + { + last_decay_at: newLastDecayDate, + }, + ); + } + + async markAllTriggersTobeDeleted() { + const deleteAt = moment + .utc() + .add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms") + .format(DBDateFormat); + await this.counterTriggers.update( + {}, + { + delete_at: deleteAt, + }, + ); + } + + async deleteTriggersMarkedToBeDeleted(): Promise { + await this.counterTriggers + .createQueryBuilder() + .where("delete_at <= NOW()") + .delete() + .execute(); + } + + async initCounterTrigger( + counterId: number, + comparisonOp: TriggerComparisonOp, + comparisonValue: number, + ): Promise { + if (!isValidComparisonOp(comparisonOp)) { + throw new Error(`Invalid comparison op: ${comparisonOp}`); + } + + if (typeof comparisonValue !== "number") { + throw new Error(`Invalid comparison value: ${comparisonValue}`); + } + + return connection.transaction(async entityManager => { + const existing = await entityManager.findOne(CounterTrigger, { + counter_id: counterId, + comparison_op: comparisonOp, + comparison_value: comparisonValue, + }); + + if (existing) { + // Since all existing triggers are marked as to-be-deleted before they are re-initialized, this needs to be reset + await entityManager.update(CounterTrigger, existing.id, { delete_at: null }); + return existing; + } + + const insertResult = await entityManager.insert(CounterTrigger, { + counter_id: counterId, + comparison_op: comparisonOp, + comparison_value: comparisonValue, + }); + + return (await entityManager.findOne(CounterTrigger, insertResult.identifiers[0].id))!; + }); + } + + /** + * Checks if a counter value with the given parameters triggers the specified comparison for the specified counter. + * If it does, mark this comparison for these parameters as triggered. + * Note that if this comparison for these parameters was already triggered previously, this function will return false. + * This means that a specific comparison for the specific parameters specified will only trigger *once* until the reverse trigger is triggered. + * + * @param counterId + * @param comparisonOp + * @param comparisonValue + * @param userId + * @param channelId + * @return Whether the given parameters newly triggered the given comparison + */ + async checkForTrigger( + counterTrigger: CounterTrigger, + channelId: string | null, + userId: string | null, + ): Promise { + channelId = channelId || "0"; + userId = userId || "0"; + + return connection.transaction(async entityManager => { + const previouslyTriggered = await entityManager.findOne(CounterTriggerState, { + trigger_id: counterTrigger.id, + user_id: userId!, + channel_id: channelId!, + }); + + if (previouslyTriggered) { + return false; + } + + const matchingValue = await entityManager + .createQueryBuilder(CounterValue, "cv") + .leftJoin( + CounterTriggerState, + "triggerStates", + "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", + { triggerId: counterTrigger.id }, + ) + .where(`cv.value ${counterTrigger.comparison_op} :value`, { value: counterTrigger.comparison_value }) + .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) + .andWhere("cv.channel_id = :channelId AND cv.user_id = :userId", { channelId, userId }) + .andWhere("triggerStates.id IS NULL") + .getOne(); + + if (matchingValue) { + await entityManager.insert(CounterTriggerState, { + trigger_id: counterTrigger.id, + user_id: userId!, + channel_id: channelId!, + }); + + return true; + } + + return false; + }); + } + + /** + * Checks if any counter values of the specified counter match the specified comparison. + * Like checkForTrigger(), this can only happen *once* per unique counter value parameters until the reverse trigger is triggered for those values. + * + * @return Counter value parameters that triggered the condition + */ + async checkAllValuesForTrigger( + counterTrigger: CounterTrigger, + ): Promise> { + return connection.transaction(async entityManager => { + const matchingValues = await entityManager + .createQueryBuilder(CounterValue, "cv") + .leftJoin( + CounterTriggerState, + "triggerStates", + "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", + { triggerId: counterTrigger.id }, + ) + .where(`cv.value ${counterTrigger.comparison_op} :value`, { value: counterTrigger.comparison_value }) + .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) + .andWhere("triggerStates.id IS NULL") + .getMany(); + + if (matchingValues.length) { + await entityManager.insert( + CounterTriggerState, + matchingValues.map(row => ({ + trigger_id: counterTrigger.id, + channelId: row.channel_id, + userId: row.user_id, + })), + ); + } + + return matchingValues.map(row => ({ + channelId: row.channel_id, + userId: row.user_id, + })); + }); + } + + /** + * Checks if a counter value with the given parameters *no longer* matches the specified comparison, and thus triggers a "reverse trigger". + * Like checkForTrigger(), this can only happen *once* until the comparison is triggered normally again. + * + * @param counterId + * @param comparisonOp + * @param comparisonValue + * @param userId + * @param channelId + * @return Whether the given parameters triggered a reverse trigger for the given comparison + */ + async checkForReverseTrigger( + counterTrigger: CounterTrigger, + channelId: string | null, + userId: string | null, + ): Promise { + channelId = channelId || "0"; + userId = userId || "0"; + + return connection.transaction(async entityManager => { + const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op); + const matchingValue = await entityManager + .createQueryBuilder(CounterValue, "cv") + .innerJoin( + CounterTriggerState, + "triggerStates", + "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", + { triggerId: counterTrigger.id }, + ) + .where(`cv.value ${reverseOp} :value`, { value: counterTrigger.comparison_value }) + .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) + .andWhere(`cv.channel_id = :channelId AND cv.user_id = :userId`, { channelId, userId }) + .getOne(); + + if (matchingValue) { + await entityManager.delete(CounterTriggerState, { + trigger_id: counterTrigger.id, + user_id: userId!, + channel_id: channelId!, + }); + + return true; + } + + return false; + }); + } + + /** + * Checks if any counter values of the specified counter *no longer* match the specified comparison, and thus triggers a "reverse trigger" for those values. + * Like checkForTrigger(), this can only happen *once* per unique counter value parameters until the comparison is triggered normally again. + * + * @return Counter value parameters that triggered a reverse trigger + */ + async checkAllValuesForReverseTrigger( + counterTrigger: CounterTrigger, + ): Promise> { + return connection.transaction(async entityManager => { + const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op); + const matchingValues: Array<{ + id: string; + triggerStateId: string; + user_id: string; + channel_id: string; + }> = await entityManager + .createQueryBuilder(CounterValue, "cv") + .innerJoin( + CounterTriggerState, + "triggerStates", + "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", + { triggerId: counterTrigger.id }, + ) + .where(`cv.value ${reverseOp} :value`, { value: counterTrigger.comparison_value }) + .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) + .select([ + "cv.id AS id", + "cv.user_id AS user_id", + "cv.channel_id AS channel_id", + "triggerStates.id AS triggerStateId", + ]) + .getRawMany(); + + if (matchingValues.length) { + await entityManager.delete(CounterTriggerState, { + id: In(matchingValues.map(v => v.triggerStateId)), + }); + } + + return matchingValues.map(row => ({ + channelId: row.channel_id, + userId: row.user_id, + })); + }); + } + + async getCurrentValue( + counterId: number, + channelId: string | null, + userId: string | null, + ): Promise { + const value = await this.counterValues.findOne({ + where: { + counter_id: counterId, + channel_id: channelId || "0", + user_id: userId || "0", + }, + }); + + return value?.value; + } +} diff --git a/backend/src/data/GuildMutes.ts b/backend/src/data/GuildMutes.ts index 7a408106..93fcfdc5 100644 --- a/backend/src/data/GuildMutes.ts +++ b/backend/src/data/GuildMutes.ts @@ -34,7 +34,7 @@ export class GuildMutes extends BaseGuildRepository { return mute != null; } - async addMute(userId, expiryTime): Promise { + async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise { const expiresAt = expiryTime ? moment .utc() @@ -46,12 +46,13 @@ export class GuildMutes extends BaseGuildRepository { guild_id: this.guildId, user_id: userId, expires_at: expiresAt, + roles_to_restore: rolesToRestore ?? [], }); return (await this.mutes.findOne({ where: result.identifiers[0] }))!; } - async updateExpiryTime(userId, newExpiryTime) { + async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) { const expiresAt = newExpiryTime ? moment .utc() @@ -59,15 +60,28 @@ export class GuildMutes extends BaseGuildRepository { .format("YYYY-MM-DD HH:mm:ss") : null; - return this.mutes.update( - { - guild_id: this.guildId, - user_id: userId, - }, - { - expires_at: expiresAt, - }, - ); + if (rolesToRestore && rolesToRestore.length) { + return this.mutes.update( + { + guild_id: this.guildId, + user_id: userId, + }, + { + expires_at: expiresAt, + roles_to_restore: rolesToRestore, + }, + ); + } else { + return this.mutes.update( + { + guild_id: this.guildId, + user_id: userId, + }, + { + expires_at: expiresAt, + }, + ); + } } async getActiveMutes(): Promise { diff --git a/backend/src/data/GuildTempbans.ts b/backend/src/data/GuildTempbans.ts new file mode 100644 index 00000000..76e126c5 --- /dev/null +++ b/backend/src/data/GuildTempbans.ts @@ -0,0 +1,75 @@ +import moment from "moment-timezone"; +import { Mute } from "./entities/Mute"; +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { Brackets, getRepository, Repository } from "typeorm"; +import { Tempban } from "./entities/Tempban"; + +export class GuildTempbans extends BaseGuildRepository { + private tempbans: Repository; + + constructor(guildId) { + super(guildId); + this.tempbans = getRepository(Tempban); + } + + async getExpiredTempbans(): Promise { + return this.tempbans + .createQueryBuilder("mutes") + .where("guild_id = :guild_id", { guild_id: this.guildId }) + .andWhere("expires_at IS NOT NULL") + .andWhere("expires_at <= NOW()") + .getMany(); + } + + async findExistingTempbanForUserId(userId: string): Promise { + return this.tempbans.findOne({ + where: { + guild_id: this.guildId, + user_id: userId, + }, + }); + } + + async addTempban(userId, expiryTime, modId): Promise { + const expiresAt = moment + .utc() + .add(expiryTime, "ms") + .format("YYYY-MM-DD HH:mm:ss"); + + const result = await this.tempbans.insert({ + guild_id: this.guildId, + user_id: userId, + mod_id: modId, + expires_at: expiresAt, + created_at: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + }); + + return (await this.tempbans.findOne({ where: result.identifiers[0] }))!; + } + + async updateExpiryTime(userId, newExpiryTime, modId) { + const expiresAt = moment + .utc() + .add(newExpiryTime, "ms") + .format("YYYY-MM-DD HH:mm:ss"); + + return this.tempbans.update( + { + guild_id: this.guildId, + user_id: userId, + }, + { + created_at: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + expires_at: expiresAt, + mod_id: modId, + }, + ); + } + + async clear(userId) { + await this.tempbans.delete({ + guild_id: this.guildId, + user_id: userId, + }); + } +} diff --git a/backend/src/data/LogType.ts b/backend/src/data/LogType.ts index 8f27bf4f..bc62cd02 100644 --- a/backend/src/data/LogType.ts +++ b/backend/src/data/LogType.ts @@ -39,17 +39,21 @@ export enum LogType { CASE_CREATE, + MASSUNBAN, MASSBAN, MASSMUTE, MEMBER_TIMED_MUTE, MEMBER_TIMED_UNMUTE, + MEMBER_TIMED_BAN, + MEMBER_TIMED_UNBAN, MEMBER_JOIN_WITH_PRIOR_RECORDS, OTHER_SPAM_DETECTED, MEMBER_ROLE_CHANGES, VOICE_CHANNEL_FORCE_MOVE, + VOICE_CHANNEL_FORCE_DISCONNECT, CASE_UPDATE, diff --git a/backend/src/data/entities/Counter.ts b/backend/src/data/entities/Counter.ts new file mode 100644 index 00000000..18f0d078 --- /dev/null +++ b/backend/src/data/entities/Counter.ts @@ -0,0 +1,25 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity("counters") +export class Counter { + @PrimaryGeneratedColumn() + id: number; + + @Column() + guild_id: string; + + @Column() + name: string; + + @Column() + per_channel: boolean; + + @Column() + per_user: boolean; + + @Column() + last_decay_at: string; + + @Column({ type: "datetime", nullable: true }) + delete_at: string | null; +} diff --git a/backend/src/data/entities/CounterTrigger.ts b/backend/src/data/entities/CounterTrigger.ts new file mode 100644 index 00000000..7cf20700 --- /dev/null +++ b/backend/src/data/entities/CounterTrigger.ts @@ -0,0 +1,23 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +export const TRIGGER_COMPARISON_OPS = ["=", "!=", ">", "<", ">=", "<="] as const; + +export type TriggerComparisonOp = typeof TRIGGER_COMPARISON_OPS[number]; + +@Entity("counter_triggers") +export class CounterTrigger { + @PrimaryGeneratedColumn() + id: number; + + @Column() + counter_id: number; + + @Column({ type: "varchar" }) + comparison_op: TriggerComparisonOp; + + @Column() + comparison_value: number; + + @Column({ type: "datetime", nullable: true }) + delete_at: string | null; +} diff --git a/backend/src/data/entities/CounterTriggerState.ts b/backend/src/data/entities/CounterTriggerState.ts new file mode 100644 index 00000000..0d5ca48e --- /dev/null +++ b/backend/src/data/entities/CounterTriggerState.ts @@ -0,0 +1,17 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity("counter_trigger_states") +export class CounterTriggerState { + @Column({ type: "bigint", generated: "increment" }) + @PrimaryColumn() + id: string; + + @Column() + trigger_id: number; + + @Column({ type: "bigint" }) + channel_id: string; + + @Column({ type: "bigint" }) + user_id: string; +} diff --git a/backend/src/data/entities/CounterValue.ts b/backend/src/data/entities/CounterValue.ts new file mode 100644 index 00000000..5647a6ec --- /dev/null +++ b/backend/src/data/entities/CounterValue.ts @@ -0,0 +1,20 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity("counter_values") +export class CounterValue { + @Column() + @PrimaryColumn() + id: string; + + @Column() + counter_id: number; + + @Column({ type: "bigint" }) + channel_id: string; + + @Column({ type: "bigint" }) + user_id: string; + + @Column() + value: number; +} diff --git a/backend/src/data/entities/Mute.ts b/backend/src/data/entities/Mute.ts index 4646645c..8e515acc 100644 --- a/backend/src/data/entities/Mute.ts +++ b/backend/src/data/entities/Mute.ts @@ -15,4 +15,6 @@ export class Mute { @Column({ type: String, nullable: true }) expires_at: string | null; @Column() case_id: number; + + @Column("simple-array") roles_to_restore: string[]; } diff --git a/backend/src/data/entities/Tempban.ts b/backend/src/data/entities/Tempban.ts new file mode 100644 index 00000000..399e94ff --- /dev/null +++ b/backend/src/data/entities/Tempban.ts @@ -0,0 +1,18 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity("tempbans") +export class Tempban { + @Column() + @PrimaryColumn() + guild_id: string; + + @Column() + @PrimaryColumn() + user_id: string; + + @Column() mod_id: string; + + @Column() created_at: string; + + @Column() expires_at: string; +} diff --git a/backend/src/migrations/1608608903570-CreateRestoredRolesColumn.ts b/backend/src/migrations/1608608903570-CreateRestoredRolesColumn.ts new file mode 100644 index 00000000..f83e8099 --- /dev/null +++ b/backend/src/migrations/1608608903570-CreateRestoredRolesColumn.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class CreateRestoredRolesColumn1608608903570 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "mutes", + new TableColumn({ + name: "roles_to_restore", + type: "text", + isNullable: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("mutes", "roles_to_restore"); + } +} diff --git a/backend/src/migrations/1608753440716-CreateTempBansTable.ts b/backend/src/migrations/1608753440716-CreateTempBansTable.ts new file mode 100644 index 00000000..7345d34c --- /dev/null +++ b/backend/src/migrations/1608753440716-CreateTempBansTable.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; + +export class CreateTempBansTable1608753440716 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.createTable( + new Table({ + name: "tempbans", + columns: [ + { + name: "guild_id", + type: "bigint", + isPrimary: true, + }, + { + name: "user_id", + type: "bigint", + isPrimary: true, + }, + { + name: "mod_id", + type: "bigint", + }, + { + name: "created_at", + type: "datetime", + }, + { + name: "expires_at", + type: "datetime", + }, + ], + }), + ); + queryRunner.createIndex( + "tempbans", + new TableIndex({ + columnNames: ["expires_at"], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("tempbans"); + } +} diff --git a/backend/src/migrations/1612010765767-CreateCounterTables.ts b/backend/src/migrations/1612010765767-CreateCounterTables.ts new file mode 100644 index 00000000..48643c4f --- /dev/null +++ b/backend/src/migrations/1612010765767-CreateCounterTables.ts @@ -0,0 +1,203 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateCounterTables1612010765767 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "counters", + columns: [ + { + name: "id", + type: "int", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "guild_id", + type: "bigint", + }, + { + name: "name", + type: "varchar", + length: "255", + }, + { + name: "per_channel", + type: "boolean", + }, + { + name: "per_user", + type: "boolean", + }, + { + name: "last_decay_at", + type: "datetime", + }, + { + name: "delete_at", + type: "datetime", + isNullable: true, + default: null, + }, + ], + indices: [ + { + columnNames: ["guild_id", "name"], + isUnique: true, + }, + { + columnNames: ["delete_at"], + }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: "counter_values", + columns: [ + { + name: "id", + type: "bigint", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "counter_id", + type: "int", + }, + { + name: "channel_id", + type: "bigint", + }, + { + name: "user_id", + type: "bigint", + }, + { + name: "value", + type: "int", + }, + ], + indices: [ + { + columnNames: ["counter_id", "channel_id", "user_id"], + isUnique: true, + }, + ], + foreignKeys: [ + { + columnNames: ["counter_id"], + referencedTableName: "counters", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: "counter_triggers", + columns: [ + { + name: "id", + type: "int", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "counter_id", + type: "int", + }, + { + name: "comparison_op", + type: "varchar", + length: "16", + }, + { + name: "comparison_value", + type: "int", + }, + { + name: "delete_at", + type: "datetime", + isNullable: true, + default: null, + }, + ], + indices: [ + { + columnNames: ["counter_id", "comparison_op", "comparison_value"], + isUnique: true, + }, + { + columnNames: ["delete_at"], + }, + ], + foreignKeys: [ + { + columnNames: ["counter_id"], + referencedTableName: "counters", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: "counter_trigger_states", + columns: [ + { + name: "id", + type: "bigint", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "trigger_id", + type: "int", + }, + { + name: "channel_id", + type: "bigint", + }, + { + name: "user_id", + type: "bigint", + }, + ], + indices: [ + { + columnNames: ["trigger_id", "channel_id", "user_id"], + isUnique: true, + }, + ], + foreignKeys: [ + { + columnNames: ["trigger_id"], + referencedTableName: "counter_triggers", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("counter_trigger_states"); + await queryRunner.dropTable("counter_triggers"); + await queryRunner.dropTable("counter_values"); + await queryRunner.dropTable("counters"); + } +} diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 9b48f809..cae48714 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -2,7 +2,7 @@ * @file Utility functions that are plugin-instance-specific (i.e. use PluginData) */ -import { Member } from "eris"; +import { AdvancedMessageContent, AllowedMentions, GuildTextableChannel, Member, Message, TextableChannel } from "eris"; import { CommandContext, configUtils, ConfigValidationError, GuildPluginData, helpers, PluginOptions } from "knub"; import { decodeAndValidateStrict, StrictValidationError, validate } from "./validatorUtils"; import { deepKeyIntersect, errorMessage, successMessage, tDeepPartial, tNullable } from "./utils"; @@ -137,18 +137,48 @@ export function getPluginConfigPreprocessor( }; } -export function sendSuccessMessage(pluginData: AnyPluginData, channel, body) { +export function sendSuccessMessage( + pluginData: AnyPluginData, + channel: TextableChannel, + body: string, + allowedMentions?: AllowedMentions, +): Promise { const emoji = pluginData.fullConfig.success_emoji || undefined; - return channel.createMessage(successMessage(body, emoji)).catch(err => { - logger.warn(`Failed to send success message to ${channel.id} (${channel.guild?.id}): ${err.code} ${err.message}`); - }); + const formattedBody = successMessage(body, emoji); + const content: AdvancedMessageContent = allowedMentions + ? { content: formattedBody, allowedMentions } + : { content: formattedBody }; + return channel + .createMessage(content) // Force line break + .catch(err => { + const channelInfo = (channel as GuildTextableChannel).guild + ? `${channel.id} (${(channel as GuildTextableChannel).guild.id})` + : `${channel.id}`; + logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); + return undefined; + }); } -export function sendErrorMessage(pluginData: AnyPluginData, channel, body) { +export function sendErrorMessage( + pluginData: AnyPluginData, + channel: TextableChannel, + body: string, + allowedMentions?: AllowedMentions, +): Promise { const emoji = pluginData.fullConfig.error_emoji || undefined; - return channel.createMessage(errorMessage(body, emoji)).catch(err => { - logger.warn(`Failed to send error message to ${channel.id} (${channel.guild?.id}): ${err.code} ${err.message}`); - }); + const formattedBody = errorMessage(body, emoji); + const content: AdvancedMessageContent = allowedMentions + ? { content: formattedBody, allowedMentions } + : { content: formattedBody }; + return channel + .createMessage(content) // Force line break + .catch(err => { + const channelInfo = (channel as GuildTextableChannel).guild + ? `${channel.id} (${(channel as GuildTextableChannel).guild.id})` + : `${channel.id}`; + logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); + return undefined; + }); } export function getBaseUrl(pluginData: AnyPluginData) { diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 84936aa6..9cd380c2 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -28,6 +28,12 @@ import { LogType } from "../../data/LogType"; import { logger } from "../../logger"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate"; +import { CountersPlugin } from "../Counters/CountersPlugin"; +import { parseCondition } from "../../data/GuildCounters"; +import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger"; +import { runAutomodOnModAction } from "./events/runAutomodOnModAction"; +import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap"; +import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap"; const defaultOptions = { config: { @@ -53,7 +59,7 @@ const defaultOptions = { }; /** - * Config preprocessor to set default values for triggers + * Config preprocessor to set default values for triggers and perform extra validation */ const configPreprocessor: ConfigPreprocessorFn = options => { if (options.config?.rules) { @@ -108,6 +114,15 @@ const configPreprocessor: ConfigPreprocessorFn = options => { ]); } } + + if (triggerName === "counter") { + const parsedCondition = parseCondition(triggerObj[triggerName]!.condition); + if (parsedCondition == null) { + throw new StrictValidationError([ + `Invalid counter condition '${triggerObj[triggerName]!.condition}' in rule <${rule.name}>`, + ]); + } + } } } } @@ -151,7 +166,13 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", showInDocs: true, info: pluginInfo, - dependencies: [LogsPlugin, ModActionsPlugin, MutesPlugin], + // prettier-ignore + dependencies: [ + LogsPlugin, + ModActionsPlugin, + MutesPlugin, + CountersPlugin, + ], configSchema: ConfigSchema, defaultOptions, @@ -161,6 +182,7 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", return criteria?.antiraid_level ? criteria.antiraid_level === pluginData.state.cachedAntiraidLevel : false; }, + // prettier-ignore events: [ RunAutomodOnJoinEvt, RunAutomodOnMemberUpdate, @@ -204,6 +226,69 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", pluginData.state.cachedAntiraidLevel = await pluginData.state.antiraidLevels.get(); }, + async onAfterLoad(pluginData) { + const countersPlugin = pluginData.getPlugin(CountersPlugin); + + pluginData.state.onCounterTrigger = (name, condition, channelId, userId) => { + runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, false); + }; + + pluginData.state.onCounterReverseTrigger = (name, condition, channelId, userId) => { + runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, true); + }; + + const config = pluginData.config.get(); + for (const rule of Object.values(config.rules)) { + for (const trigger of rule.triggers) { + if (trigger.counter) { + await countersPlugin.initCounterTrigger(trigger.counter.name, trigger.counter.condition); + } + } + } + + countersPlugin.onCounterEvent("trigger", pluginData.state.onCounterTrigger); + countersPlugin.onCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger); + + const modActionsEvents = pluginData.getPlugin(ModActionsPlugin).getEventEmitter(); + pluginData.state.modActionsListeners = new Map(); + pluginData.state.modActionsListeners.set("note", (userId: string) => + runAutomodOnModAction(pluginData, "note", userId), + ); + pluginData.state.modActionsListeners.set("warn", (userId: string) => + runAutomodOnModAction(pluginData, "warn", userId), + ); + pluginData.state.modActionsListeners.set("kick", (userId: string) => + runAutomodOnModAction(pluginData, "kick", userId), + ); + pluginData.state.modActionsListeners.set("ban", (userId: string) => + runAutomodOnModAction(pluginData, "ban", userId), + ); + pluginData.state.modActionsListeners.set("unban", (userId: string) => + runAutomodOnModAction(pluginData, "unban", userId), + ); + registerEventListenersFromMap(modActionsEvents, pluginData.state.modActionsListeners); + + const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter(); + pluginData.state.mutesListeners = new Map(); + pluginData.state.mutesListeners.set("mute", (userId: string) => runAutomodOnModAction(pluginData, "mute", userId)); + pluginData.state.mutesListeners.set("unmute", (userId: string) => + runAutomodOnModAction(pluginData, "unmute", userId), + ); + registerEventListenersFromMap(mutesEvents, pluginData.state.mutesListeners); + }, + + async onBeforeUnload(pluginData) { + const countersPlugin = pluginData.getPlugin(CountersPlugin); + countersPlugin.offCounterEvent("trigger", pluginData.state.onCounterTrigger); + countersPlugin.offCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger); + + const modActionsEvents = pluginData.getPlugin(ModActionsPlugin).getEventEmitter(); + unregisterEventListenersFromMap(modActionsEvents, pluginData.state.modActionsListeners); + + const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter(); + unregisterEventListenersFromMap(mutesEvents, pluginData.state.mutesListeners); + }, + async onUnload(pluginData) { pluginData.state.queue.clear(); diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts index 51e716d1..f4a098c9 100644 --- a/backend/src/plugins/Automod/actions/alert.ts +++ b/backend/src/plugins/Automod/actions/alert.ts @@ -37,7 +37,7 @@ export const AlertAction = automodAction({ const safeUser = safeUsers[0]; const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", "); - const logMessage = logs.getLogMessage(LogType.AUTOMOD_ACTION, { + const logMessage = await logs.getLogMessage(LogType.AUTOMOD_ACTION, { rule: ruleName, user: safeUser, users: safeUsers, diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts index 6b2c0b6f..21ebe945 100644 --- a/backend/src/plugins/Automod/actions/availableActions.ts +++ b/backend/src/plugins/Automod/actions/availableActions.ts @@ -12,6 +12,7 @@ import { AddRolesAction } from "./addRoles"; import { RemoveRolesAction } from "./removeRoles"; import { SetAntiraidLevelAction } from "./setAntiraidLevel"; import { ReplyAction } from "./reply"; +import { ChangeCounterAction } from "./changeCounter"; export const availableActions: Record> = { clean: CleanAction, @@ -26,6 +27,7 @@ export const availableActions: Record> = { remove_roles: RemoveRolesAction, set_antiraid_level: SetAntiraidLevelAction, reply: ReplyAction, + change_counter: ChangeCounterAction, }; export const AvailableActions = t.type({ @@ -41,4 +43,5 @@ export const AvailableActions = t.type({ remove_roles: RemoveRolesAction.configType, set_antiraid_level: SetAntiraidLevelAction.configType, reply: ReplyAction.configType, + change_counter: ChangeCounterAction.configType, }); diff --git a/backend/src/plugins/Automod/actions/changeCounter.ts b/backend/src/plugins/Automod/actions/changeCounter.ts new file mode 100644 index 00000000..a3db8d31 --- /dev/null +++ b/backend/src/plugins/Automod/actions/changeCounter.ts @@ -0,0 +1,27 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { CountersPlugin } from "../../Counters/CountersPlugin"; + +export const ChangeCounterAction = automodAction({ + configType: t.type({ + name: t.string, + change: t.string, + }), + + defaultConfig: {}, + + async apply({ pluginData, contexts, actionConfig, matchResult }) { + const change = parseInt(actionConfig.change, 10); + if (Number.isNaN(change)) { + throw new Error("Invalid change number"); + } + + const countersPlugin = pluginData.getPlugin(CountersPlugin); + countersPlugin.changeCounterValue( + actionConfig.name, + contexts[0].message?.channel_id || null, + contexts[0].user?.id || null, + change, + ); + }, +}); diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts index 324f763a..6115a042 100644 --- a/backend/src/plugins/Automod/actions/mute.ts +++ b/backend/src/plugins/Automod/actions/mute.ts @@ -22,6 +22,8 @@ export const MuteAction = automodAction({ duration: tNullable(tDelayString), notify: tNullable(t.string), notifyChannel: tNullable(t.string), + remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), + restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), }), defaultConfig: { @@ -32,6 +34,8 @@ export const MuteAction = automodAction({ const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined; const reason = actionConfig.reason || "Muted automatically"; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; + const rolesToRemove = actionConfig.remove_roles_on_mute; + const rolesToRestore = actionConfig.restore_roles_on_mute; const caseArgs = { modId: pluginData.client.user.id, @@ -43,7 +47,7 @@ export const MuteAction = automodAction({ const mutes = pluginData.getPlugin(MutesPlugin); for (const userId of userIdsToMute) { try { - await mutes.muteUser(userId, duration, reason, { contactMethods, caseArgs }); + await mutes.muteUser(userId, duration, reason, { contactMethods, caseArgs }, rolesToRemove, rolesToRestore); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts index 664ee083..6049cbf2 100644 --- a/backend/src/plugins/Automod/actions/removeRoles.ts +++ b/backend/src/plugins/Automod/actions/removeRoles.ts @@ -66,11 +66,15 @@ export const RemoveRolesAction = automodAction({ return; } + const memberRolesLock = await pluginData.locks.acquire(`member-roles-${member.id}`); + const rolesArr = Array.from(memberRoles.values()); await member.edit({ roles: rolesArr, }); member.roles = rolesArr; // Make sure we know of the new roles internally as well + + memberRolesLock.unlock(); }), ); }, diff --git a/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts b/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts new file mode 100644 index 00000000..50939d80 --- /dev/null +++ b/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts @@ -0,0 +1,34 @@ +import { GuildPluginData } from "knub"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { runAutomod } from "../functions/runAutomod"; +import { resolveMember, resolveUser, UnknownUser } from "../../../utils"; + +export async function runAutomodOnCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + condition: string, + channelId: string | null, + userId: string | null, + reverse: boolean, +) { + const user = userId ? await resolveUser(pluginData.client, userId) : undefined; + + const member = (userId && (await resolveMember(pluginData.client, pluginData.guild, userId))) || undefined; + + const context: AutomodContext = { + timestamp: Date.now(), + counterTrigger: { + name: counterName, + condition, + channelId, + userId, + reverse, + }, + user: user instanceof UnknownUser ? undefined : user, + member, + }; + + pluginData.state.queue.add(async () => { + await runAutomod(pluginData, context); + }); +} diff --git a/backend/src/plugins/Automod/events/runAutomodOnModAction.ts b/backend/src/plugins/Automod/events/runAutomodOnModAction.ts new file mode 100644 index 00000000..5831f1eb --- /dev/null +++ b/backend/src/plugins/Automod/events/runAutomodOnModAction.ts @@ -0,0 +1,27 @@ +import { GuildPluginData } from "knub"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { runAutomod } from "../functions/runAutomod"; +import { resolveUser, UnknownUser } from "../../../utils"; +import { ModActionType } from "../../ModActions/types"; + +export async function runAutomodOnModAction( + pluginData: GuildPluginData, + modAction: ModActionType, + userId: string, + reason?: string, +) { + const user = await resolveUser(pluginData.client, userId); + + const context: AutomodContext = { + timestamp: Date.now(), + user: user instanceof UnknownUser ? undefined : user, + modAction: { + type: modAction, + reason, + }, + }; + + pluginData.state.queue.add(async () => { + await runAutomod(pluginData, context); + }); +} diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts index 661d9f13..3d3af219 100644 --- a/backend/src/plugins/Automod/functions/runAutomod.ts +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -24,7 +24,7 @@ export async function runAutomod(pluginData: GuildPluginData, for (const [ruleName, rule] of Object.entries(config.rules)) { if (rule.enabled === false) continue; - if (!rule.affects_bots && (!user || user.bot)) continue; + if (!rule.affects_bots && (!user || user.bot) && !context.counterTrigger) continue; if (rule.cooldown && checkAndUpdateCooldown(pluginData, rule, context)) { return; diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index e178a256..175df6d7 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -17,6 +17,14 @@ import { MemberJoinTrigger } from "./memberJoin"; import { RoleAddedTrigger } from "./roleAdded"; import { RoleRemovedTrigger } from "./roleRemoved"; import { StickerSpamTrigger } from "./stickerSpam"; +import { CounterTrigger } from "./counter"; +import { NoteTrigger } from "./note"; +import { WarnTrigger } from "./warn"; +import { MuteTrigger } from "./mute"; +import { UnmuteTrigger } from "./unmute"; +import { KickTrigger } from "./kick"; +import { BanTrigger } from "./ban"; +import { UnbanTrigger } from "./unban"; export const availableTriggers: Record> = { match_words: MatchWordsTrigger, @@ -37,6 +45,16 @@ export const availableTriggers: Record character_spam: CharacterSpamTrigger, member_join_spam: MemberJoinSpamTrigger, sticker_spam: StickerSpamTrigger, + + counter: CounterTrigger, + + note: NoteTrigger, + warn: WarnTrigger, + mute: MuteTrigger, + unmute: UnmuteTrigger, + kick: KickTrigger, + ban: BanTrigger, + unban: UnbanTrigger, }; export const AvailableTriggers = t.type({ @@ -58,4 +76,14 @@ export const AvailableTriggers = t.type({ character_spam: CharacterSpamTrigger.configType, member_join_spam: MemberJoinSpamTrigger.configType, sticker_spam: StickerSpamTrigger.configType, + + counter: CounterTrigger.configType, + + note: NoteTrigger.configType, + warn: WarnTrigger.configType, + mute: MuteTrigger.configType, + unmute: UnmuteTrigger.configType, + kick: KickTrigger.configType, + ban: BanTrigger.configType, + unban: UnbanTrigger.configType, }); diff --git a/backend/src/plugins/Automod/triggers/ban.ts b/backend/src/plugins/Automod/triggers/ban.ts new file mode 100644 index 00000000..64559e74 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/ban.ts @@ -0,0 +1,24 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +// tslint:disable-next-line:no-empty-interface +interface BanTriggerResultType {} + +export const BanTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (context.modAction?.type !== "ban") { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult }) { + return `User was banned`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/counter.ts b/backend/src/plugins/Automod/triggers/counter.ts new file mode 100644 index 00000000..6b3c3288 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/counter.ts @@ -0,0 +1,46 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges"; +import { CountersPlugin } from "../../Counters/CountersPlugin"; +import { tNullable } from "../../../utils"; + +// tslint:disable-next-line +interface CounterTriggerResult {} + +export const CounterTrigger = automodTrigger()({ + configType: t.type({ + name: t.string, + condition: t.string, + reverse: tNullable(t.boolean), + }), + + defaultConfig: {}, + + async match({ triggerConfig, context, pluginData }) { + if (!context.counterTrigger) { + return; + } + + if (context.counterTrigger.name !== triggerConfig.name) { + return; + } + + if (context.counterTrigger.condition !== triggerConfig.condition) { + return; + } + + const reverse = triggerConfig.reverse ?? false; + if (context.counterTrigger.reverse !== reverse) { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult, pluginData, contexts, triggerConfig }) { + // TODO: Show user, channel, reverse + return `Matched counter \`${triggerConfig.name} ${triggerConfig.condition}\``; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/kick.ts b/backend/src/plugins/Automod/triggers/kick.ts new file mode 100644 index 00000000..284a867d --- /dev/null +++ b/backend/src/plugins/Automod/triggers/kick.ts @@ -0,0 +1,24 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +// tslint:disable-next-line:no-empty-interface +interface KickTriggerResultType {} + +export const KickTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (context.modAction?.type !== "kick") { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult }) { + return `User was kicked`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/mute.ts b/backend/src/plugins/Automod/triggers/mute.ts new file mode 100644 index 00000000..94d14437 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/mute.ts @@ -0,0 +1,24 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +// tslint:disable-next-line:no-empty-interface +interface MuteTriggerResultType {} + +export const MuteTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (context.modAction?.type !== "mute") { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult }) { + return `User was muted`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/note.ts b/backend/src/plugins/Automod/triggers/note.ts new file mode 100644 index 00000000..14ef0abc --- /dev/null +++ b/backend/src/plugins/Automod/triggers/note.ts @@ -0,0 +1,24 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +// tslint:disable-next-line:no-empty-interface +interface NoteTriggerResultType {} + +export const NoteTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (context.modAction?.type !== "note") { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult }) { + return `Note was added on user`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/unban.ts b/backend/src/plugins/Automod/triggers/unban.ts new file mode 100644 index 00000000..38e86db9 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/unban.ts @@ -0,0 +1,24 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +// tslint:disable-next-line:no-empty-interface +interface UnbanTriggerResultType {} + +export const UnbanTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (context.modAction?.type !== "unban") { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult }) { + return `User was unbanned`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/unmute.ts b/backend/src/plugins/Automod/triggers/unmute.ts new file mode 100644 index 00000000..9ccb5031 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/unmute.ts @@ -0,0 +1,24 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +// tslint:disable-next-line:no-empty-interface +interface UnmuteTriggerResultType {} + +export const UnmuteTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (context.modAction?.type !== "unmute") { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult }) { + return `User was unmuted`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/warn.ts b/backend/src/plugins/Automod/triggers/warn.ts new file mode 100644 index 00000000..711f5cd7 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/warn.ts @@ -0,0 +1,24 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +// tslint:disable-next-line:no-empty-interface +interface WarnTriggerResultType {} + +export const WarnTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (context.modAction?.type !== "warn") { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult }) { + return `User was warned`; + }, +}); diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index 6b71e301..f6e273cb 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -13,6 +13,9 @@ import { GuildArchives } from "../../data/GuildArchives"; import { RecentActionType } from "./constants"; import Timeout = NodeJS.Timeout; import { RegExpRunner } from "../../RegExpRunner"; +import { CounterEvents } from "../Counters/types"; +import { ModActionsEvents, ModActionType } from "../ModActions/types"; +import { MutesEvents } from "../Mutes/types"; export const Rule = t.type({ enabled: t.boolean, @@ -86,6 +89,12 @@ export interface AutomodPluginType extends BasePluginType { onMessageCreateFn: any; onMessageUpdateFn: any; + + onCounterTrigger: CounterEvents["trigger"]; + onCounterReverseTrigger: CounterEvents["reverseTrigger"]; + + modActionsListeners: Map; + mutesListeners: Map; }; } @@ -93,6 +102,13 @@ export interface AutomodContext { timestamp: number; actioned?: boolean; + counterTrigger?: { + name: string; + condition: string; + channelId: string | null; + userId: string | null; + reverse: boolean; + }; user?: User; message?: SavedMessage; member?: Member; @@ -101,6 +117,10 @@ export interface AutomodContext { added?: string[]; removed?: string[]; }; + modAction?: { + type: ModActionType; + reason?: string; + }; } export interface RecentAction { diff --git a/backend/src/plugins/BotControl/BotControlPlugin.ts b/backend/src/plugins/BotControl/BotControlPlugin.ts index 5e871612..c36b09b8 100644 --- a/backend/src/plugins/BotControl/BotControlPlugin.ts +++ b/backend/src/plugins/BotControl/BotControlPlugin.ts @@ -15,6 +15,8 @@ import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd"; import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd"; import { Configs } from "../../data/Configs"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments"; +import { ListDashboardUsersCmd } from "./commands/ListDashboardUsersCmd"; +import { ListDashboardPermsCmd } from "./commands/ListDashboardPermsCmd"; const defaultOptions = { config: { @@ -37,6 +39,8 @@ export const BotControlPlugin = zeppelinGlobalPlugin()("bo DisallowServerCmd, AddDashboardUserCmd, RemoveDashboardUserCmd, + ListDashboardUsersCmd, + ListDashboardPermsCmd, ], onLoad(pluginData) { diff --git a/backend/src/plugins/BotControl/commands/AllowServerCmd.ts b/backend/src/plugins/BotControl/commands/AllowServerCmd.ts index 5e7f8888..7d969f8f 100644 --- a/backend/src/plugins/BotControl/commands/AllowServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/AllowServerCmd.ts @@ -2,6 +2,7 @@ import { botControlCmd } from "../types"; import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { isSnowflake } from "../../../utils"; +import { ApiPermissions } from "@shared/apiPermissions"; export const AllowServerCmd = botControlCmd({ trigger: ["allow_server", "allowserver", "add_server", "addserver"], @@ -12,6 +13,7 @@ export const AllowServerCmd = botControlCmd({ signature: { guildId: ct.string(), + userId: ct.string({ required: false }), }, async run({ pluginData, message: msg, args }) { @@ -26,8 +28,18 @@ export const AllowServerCmd = botControlCmd({ return; } + if (args.userId && !isSnowflake(args.userId)) { + sendErrorMessage(pluginData, msg.channel, "Invalid user ID!"); + return; + } + await pluginData.state.allowedGuilds.add(args.guildId); await pluginData.state.configs.saveNewRevision(`guild-${args.guildId}`, "plugins: {}", msg.author.id); + + if (args.userId) { + await pluginData.state.apiPermissionAssignments.addUser(args.guildId, args.userId, [ApiPermissions.EditConfig]); + } + sendSuccessMessage(pluginData, msg.channel, "Server is now allowed to use Zeppelin!"); }, }); diff --git a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts new file mode 100644 index 00000000..d08bc890 --- /dev/null +++ b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts @@ -0,0 +1,78 @@ +import { botControlCmd } from "../types"; +import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { createChunkedMessage, resolveUser } from "../../../utils"; +import { AllowedGuild } from "../../../data/entities/AllowedGuild"; +import { ApiPermissionAssignment } from "../../../data/entities/ApiPermissionAssignment"; + +export const ListDashboardPermsCmd = botControlCmd({ + trigger: ["list_dashboard_permissions", "list_dashboard_perms", "list_dash_permissionss", "list_dash_perms"], + permission: null, + config: { + preFilters: [isOwnerPreFilter], + }, + + signature: { + guildId: ct.string({ option: true, shortcut: "g" }), + user: ct.resolvedUser({ option: true, shortcut: "u" }), + }, + + async run({ pluginData, message: msg, args }) { + if (!args.user && !args.guildId) { + sendErrorMessage(pluginData, msg.channel, "Must specify at least guildId, user, or both."); + return; + } + + let guild: AllowedGuild | undefined; + if (args.guildId) { + guild = await pluginData.state.allowedGuilds.find(args.guildId); + if (!guild) { + sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + return; + } + } + + let existingUserAssignment: ApiPermissionAssignment[]; + if (args.user) { + existingUserAssignment = await pluginData.state.apiPermissionAssignments.getByUserId(args.user.id); + if (existingUserAssignment.length === 0) { + sendErrorMessage(pluginData, msg.channel, "The user has no assigned permissions."); + return; + } + } + + let finalMessage = ""; + + // If we have user, always display which guilds they have permissions in (or only specified guild permissions) + if (args.user) { + for (const assignment of existingUserAssignment!) { + if (guild != null && assignment.guild_id !== args.guildId) continue; + finalMessage += `The user has the following permissions on server \`${ + assignment.guild_id + }\`:\n${assignment.permissions.join("\n")}\n\n`; + } + + if (finalMessage === "") { + sendErrorMessage(pluginData, msg.channel, "The user has no assigned permissions on the specified server."); + return; + } + // Else display all users that have permissions on the specified guild + } else if (guild) { + const existingGuildAssignment = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id); + if (existingGuildAssignment.length === 0) { + sendErrorMessage(pluginData, msg.channel, "The server has no assigned permissions."); + return; + } + + finalMessage += `The server \`${guild.id}\` has the following assigned permissions:\n`; // Double \n for consistency with AddDashboardUserCmd + for (const assignment of existingGuildAssignment) { + const user = await resolveUser(pluginData.client, assignment.target_id); + finalMessage += `\n**${user.username}#${user.discriminator}**, \`${ + assignment.target_id + }\`: ${assignment.permissions.join(", ")}`; + } + } + + await sendSuccessMessage(pluginData, msg.channel, finalMessage.trim(), {}); + }, +}); diff --git a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts new file mode 100644 index 00000000..5c856275 --- /dev/null +++ b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts @@ -0,0 +1,38 @@ +import { botControlCmd } from "../types"; +import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { ApiPermissions } from "@shared/apiPermissions"; +import { resolveUser } from "../../../utils"; + +export const ListDashboardUsersCmd = botControlCmd({ + trigger: ["list_dashboard_users"], + permission: null, + config: { + preFilters: [isOwnerPreFilter], + }, + + signature: { + guildId: ct.string(), + }, + + async run({ pluginData, message: msg, args }) { + const guild = await pluginData.state.allowedGuilds.find(args.guildId); + if (!guild) { + sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + return; + } + + const dashboardUsers = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id); + const users = await Promise.all(dashboardUsers.map(perm => resolveUser(pluginData.client, perm.target_id))); + const userNameList = users.map( + user => `<@!${user.id}> (**${user.username}#${user.discriminator}**, \`${user.id}\`)`, + ); + + sendSuccessMessage( + pluginData, + msg.channel, + `The following users have dashboard access for **${guild.name}**:\n\n${userNameList}`, + {}, + ); + }, +}); diff --git a/backend/src/plugins/Cases/functions/getCaseSummary.ts b/backend/src/plugins/Cases/functions/getCaseSummary.ts index a1395f63..d948fdac 100644 --- a/backend/src/plugins/Cases/functions/getCaseSummary.ts +++ b/backend/src/plugins/Cases/functions/getCaseSummary.ts @@ -16,6 +16,7 @@ import { humanizeDurationShort } from "../../../humanizeDurationShort"; import { caseAbbreviations } from "../caseAbbreviations"; import { getCaseIcon } from "./getCaseIcon"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; +import { splitIntoCleanChunks, splitMessageIntoChunks } from "knub/dist/helpers"; const CASE_SUMMARY_REASON_MAX_LENGTH = 300; const INCLUDE_MORE_NOTES_THRESHOLD = 20; @@ -49,9 +50,8 @@ export async function getCaseSummary( if (reason.length > CASE_SUMMARY_REASON_MAX_LENGTH) { const match = reason.slice(CASE_SUMMARY_REASON_MAX_LENGTH, 100).match(/(?:[.,!?\s]|$)/); const nextWhitespaceIndex = match ? CASE_SUMMARY_REASON_MAX_LENGTH + match.index! : CASE_SUMMARY_REASON_MAX_LENGTH; - if (nextWhitespaceIndex < reason.length) { - reason = reason.slice(0, nextWhitespaceIndex - 1) + "..."; - } + const reasonChunks = splitMessageIntoChunks(reason, nextWhitespaceIndex); + reason = reasonChunks[0] + "..."; } reason = disableLinkPreviews(reason); diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts new file mode 100644 index 00000000..56af0f96 --- /dev/null +++ b/backend/src/plugins/Counters/CountersPlugin.ts @@ -0,0 +1,149 @@ +import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, CountersPluginType } from "./types"; +import { GuildCounters } from "../../data/GuildCounters"; +import { mapToPublicFn } from "../../pluginUtils"; +import { changeCounterValue } from "./functions/changeCounterValue"; +import { setCounterValue } from "./functions/setCounterValue"; +import { convertDelayStringToMS, MINUTES, SECONDS } from "../../utils"; +import { EventEmitter } from "events"; +import { onCounterEvent } from "./functions/onCounterEvent"; +import { offCounterEvent } from "./functions/offCounterEvent"; +import { emitCounterEvent } from "./functions/emitCounterEvent"; +import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; +import { initCounterTrigger } from "./functions/initCounterTrigger"; +import { decayCounter } from "./functions/decayCounter"; +import { validateCondition } from "./functions/validateCondition"; +import { StrictValidationError } from "../../validatorUtils"; +import { PluginOptions } from "knub"; +import { ViewCounterCmd } from "./commands/ViewCounterCmd"; +import { AddCounterCmd } from "./commands/AddCounterCmd"; +import { SetCounterCmd } from "./commands/SetCounterCmd"; + +const MAX_COUNTERS = 5; +const DECAY_APPLY_INTERVAL = 5 * MINUTES; + +const defaultOptions: PluginOptions = { + config: { + counters: {}, + can_view: false, + can_edit: false, + }, + overrides: [ + { + level: ">=50", + config: { + can_view: true, + }, + }, + { + level: ">=100", + config: { + can_edit: true, + }, + }, + ], +}; + +const configPreprocessor: ConfigPreprocessorFn = options => { + for (const counter of Object.values(options.config?.counters || {})) { + counter.per_user = counter.per_user ?? false; + counter.per_channel = counter.per_channel ?? false; + counter.initial_value = counter.initial_value ?? 0; + } + + if (Object.values(options.config?.counters || {}).length > MAX_COUNTERS) { + throw new StrictValidationError([`You can only have at most ${MAX_COUNTERS} active counters`]); + } + + return options; +}; + +/** + * The Counters plugin keeps track of simple integer values that are tied to a user, channel, both, or neither — "counters". + * These values can be changed using the functions in the plugin's public interface. + * These values can also be set to automatically decay over time. + * + * Triggers can be registered that check for a specific condition, e.g. "when this counter is over 100". + * Triggers are checked against every time a counter's value changes, and will emit an event when triggered. + * A single trigger can only trigger once per user/channel/in general, depending on how specific the counter is (e.g. a per-user trigger can only trigger once per user). + * After being triggered, a trigger is "reset" if the counter value no longer matches the trigger (e.g. drops to 100 or below in the above example). After this, that trigger can be triggered again. + */ +export const CountersPlugin = zeppelinGuildPlugin()("counters", { + configSchema: ConfigSchema, + defaultOptions, + configPreprocessor, + + public: { + // Change a counter's value by a relative amount, e.g. +5 + changeCounterValue: mapToPublicFn(changeCounterValue), + // Set a counter's value to an absolute value + setCounterValue: mapToPublicFn(setCounterValue), + + // Initialize a trigger. Once initialized, events will be fired when this trigger is triggered. + initCounterTrigger: mapToPublicFn(initCounterTrigger), + + // Validate a trigger's condition string + validateCondition: mapToPublicFn(validateCondition), + + onCounterEvent: mapToPublicFn(onCounterEvent), + offCounterEvent: mapToPublicFn(offCounterEvent), + }, + + // prettier-ignore + commands: [ + ViewCounterCmd, + AddCounterCmd, + SetCounterCmd, + ], + + async onLoad(pluginData) { + pluginData.state.counters = new GuildCounters(pluginData.guild.id); + pluginData.state.events = new EventEmitter(); + + // Initialize and store the IDs of each of the counters internally + pluginData.state.counterIds = {}; + const config = pluginData.config.get(); + for (const [counterName, counter] of Object.entries(config.counters)) { + const dbCounter = await pluginData.state.counters.findOrCreateCounter( + counterName, + counter.per_channel, + counter.per_user, + ); + pluginData.state.counterIds[counterName] = dbCounter.id; + } + + // Mark old/unused counters to be deleted later + await pluginData.state.counters.markUnusedCountersToBeDeleted([...Object.values(pluginData.state.counterIds)]); + + // Start decay timers + pluginData.state.decayTimers = []; + for (const [counterName, counter] of Object.entries(config.counters)) { + if (!counter.decay) { + continue; + } + + const decay = counter.decay; + const decayPeriodMs = convertDelayStringToMS(decay.every)!; + pluginData.state.decayTimers.push( + setInterval(() => { + decayCounter(pluginData, counterName, decayPeriodMs, decay.amount); + }, DECAY_APPLY_INTERVAL), + ); + } + + // Initially set the counter trigger map to just an empty map + // The actual triggers are added by other plugins via initCounterTrigger() + pluginData.state.counterTriggersByCounterId = new Map(); + + // Mark all triggers to be deleted later. This is cancelled/reset when a plugin adds the trigger again via initCounterTrigger(). + await pluginData.state.counters.markAllTriggersTobeDeleted(); + }, + + onUnload(pluginData) { + for (const interval of pluginData.state.decayTimers) { + clearInterval(interval); + } + + pluginData.state.events.removeAllListeners(); + }, +}); diff --git a/backend/src/plugins/Counters/commands/AddCounterCmd.ts b/backend/src/plugins/Counters/commands/AddCounterCmd.ts new file mode 100644 index 00000000..30bc189e --- /dev/null +++ b/backend/src/plugins/Counters/commands/AddCounterCmd.ts @@ -0,0 +1,141 @@ +import { guildCommand } from "knub"; +import { CountersPluginType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { resolveChannel, waitForReply } from "knub/dist/helpers"; +import { TextChannel, User } from "eris"; +import { resolveUser, UnknownUser } from "../../../utils"; +import { changeCounterValue } from "../functions/changeCounterValue"; + +export const AddCounterCmd = guildCommand()({ + trigger: ["counters add", "counter add", "addcounter"], + permission: "can_edit", + + signature: [ + { + counterName: ct.string(), + amount: ct.number(), + }, + { + counterName: ct.string(), + user: ct.resolvedUser(), + amount: ct.number(), + }, + { + counterName: ct.string(), + channel: ct.textChannel(), + amount: ct.number(), + }, + { + counterName: ct.string(), + channel: ct.textChannel(), + user: ct.resolvedUser(), + amount: ct.number(), + }, + { + counterName: ct.string(), + user: ct.resolvedUser(), + channel: ct.textChannel(), + amount: ct.number(), + }, + ], + + async run({ pluginData, message, args }) { + const config = pluginData.config.getForMessage(message); + const counter = config.counters[args.counterName]; + const counterId = pluginData.state.counterIds[args.counterName]; + if (!counter || !counterId) { + sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + return; + } + + if (counter.can_edit === false) { + sendErrorMessage(pluginData, message.channel, `Missing permissions to edit this counter's value`); + return; + } + + if (args.channel && !counter.per_channel) { + sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + return; + } + + if (args.user && !counter.per_user) { + sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + return; + } + + let channel = args.channel; + if (!channel && counter.per_channel) { + message.channel.createMessage(`Which channel's counter value would you like to add to?`); + const reply = await waitForReply(pluginData.client, message.channel, message.author.id); + if (!reply || !reply.content) { + sendErrorMessage(pluginData, message.channel, "Cancelling"); + return; + } + + const potentialChannel = resolveChannel(pluginData.guild, reply.content); + if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { + sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + return; + } + + channel = potentialChannel; + } + + let user = args.user; + if (!user && counter.per_user) { + message.channel.createMessage(`Which user's counter value would you like to add to?`); + const reply = await waitForReply(pluginData.client, message.channel, message.author.id); + if (!reply || !reply.content) { + sendErrorMessage(pluginData, message.channel, "Cancelling"); + return; + } + + const potentialUser = await resolveUser(pluginData.client, reply.content); + if (!potentialUser || potentialUser instanceof UnknownUser) { + sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + return; + } + + user = potentialUser; + } + + let amount = args.amount; + if (!amount) { + message.channel.createMessage("How much would you like to add to the counter's value?"); + const reply = await waitForReply(pluginData.client, message.channel, message.author.id); + if (!reply || !reply.content) { + sendErrorMessage(pluginData, message.channel, "Cancelling"); + return; + } + + const potentialAmount = parseInt(reply.content, 10); + if (!potentialAmount) { + sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); + return; + } + + amount = potentialAmount; + } + + await changeCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, amount); + const newValue = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null); + const counterName = counter.name || args.counterName; + + if (channel && user) { + message.channel.createMessage( + `Added ${amount} to **${counterName}** for <@!${user.id}> in <#${channel.id}>. The value is now ${newValue}.`, + ); + } else if (channel) { + message.channel.createMessage( + `Added ${amount} to **${counterName}** in <#${channel.id}>. The value is now ${newValue}.`, + ); + } else if (user) { + message.channel.createMessage( + `Added ${amount} to **${counterName}** for <@!${user.id}>. The value is now ${newValue}.`, + ); + } else { + message.channel.createMessage(`Added ${amount} to **${counterName}**. The value is now ${newValue}.`); + } + }, +}); diff --git a/backend/src/plugins/Counters/commands/SetCounterCmd.ts b/backend/src/plugins/Counters/commands/SetCounterCmd.ts new file mode 100644 index 00000000..edabe2e7 --- /dev/null +++ b/backend/src/plugins/Counters/commands/SetCounterCmd.ts @@ -0,0 +1,140 @@ +import { guildCommand } from "knub"; +import { CountersPluginType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { resolveChannel, waitForReply } from "knub/dist/helpers"; +import { TextChannel, User } from "eris"; +import { resolveUser, UnknownUser } from "../../../utils"; +import { changeCounterValue } from "../functions/changeCounterValue"; +import { setCounterValue } from "../functions/setCounterValue"; + +export const SetCounterCmd = guildCommand()({ + trigger: ["counters set", "counter set", "setcounter"], + permission: "can_edit", + + signature: [ + { + counterName: ct.string(), + value: ct.number(), + }, + { + counterName: ct.string(), + user: ct.resolvedUser(), + value: ct.number(), + }, + { + counterName: ct.string(), + channel: ct.textChannel(), + value: ct.number(), + }, + { + counterName: ct.string(), + channel: ct.textChannel(), + user: ct.resolvedUser(), + value: ct.number(), + }, + { + counterName: ct.string(), + user: ct.resolvedUser(), + channel: ct.textChannel(), + value: ct.number(), + }, + ], + + async run({ pluginData, message, args }) { + const config = pluginData.config.getForMessage(message); + const counter = config.counters[args.counterName]; + const counterId = pluginData.state.counterIds[args.counterName]; + if (!counter || !counterId) { + sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + return; + } + + if (counter.can_edit === false) { + sendErrorMessage(pluginData, message.channel, `Missing permissions to edit this counter's value`); + return; + } + + if (args.channel && !counter.per_channel) { + sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + return; + } + + if (args.user && !counter.per_user) { + sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + return; + } + + let channel = args.channel; + if (!channel && counter.per_channel) { + message.channel.createMessage(`Which channel's counter value would you like to add to?`); + const reply = await waitForReply(pluginData.client, message.channel, message.author.id); + if (!reply || !reply.content) { + sendErrorMessage(pluginData, message.channel, "Cancelling"); + return; + } + + const potentialChannel = resolveChannel(pluginData.guild, reply.content); + if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { + sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + return; + } + + channel = potentialChannel; + } + + let user = args.user; + if (!user && counter.per_user) { + message.channel.createMessage(`Which user's counter value would you like to add to?`); + const reply = await waitForReply(pluginData.client, message.channel, message.author.id); + if (!reply || !reply.content) { + sendErrorMessage(pluginData, message.channel, "Cancelling"); + return; + } + + const potentialUser = await resolveUser(pluginData.client, reply.content); + if (!potentialUser || potentialUser instanceof UnknownUser) { + sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + return; + } + + user = potentialUser; + } + + let value = args.value; + if (!value) { + message.channel.createMessage("How much would you like to add to the counter's value?"); + const reply = await waitForReply(pluginData.client, message.channel, message.author.id); + if (!reply || !reply.content) { + sendErrorMessage(pluginData, message.channel, "Cancelling"); + return; + } + + const potentialValue = parseInt(reply.content, 10); + if (!potentialValue) { + sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); + return; + } + + value = potentialValue; + } + + if (value < 0) { + sendErrorMessage(pluginData, message.channel, "Cannot set counter value below 0"); + return; + } + + await setCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, value); + const counterName = counter.name || args.counterName; + + if (channel && user) { + message.channel.createMessage(`Set **${counterName}** for <@!${user.id}> in <#${channel.id}> to ${value}`); + } else if (channel) { + message.channel.createMessage(`Set **${counterName}** in <#${channel.id}> to ${value}`); + } else if (user) { + message.channel.createMessage(`Set **${counterName}** for <@!${user.id}> to ${value}`); + } else { + message.channel.createMessage(`Set **${counterName}** to ${value}`); + } + }, +}); diff --git a/backend/src/plugins/Counters/commands/ViewCounterCmd.ts b/backend/src/plugins/Counters/commands/ViewCounterCmd.ts new file mode 100644 index 00000000..9c98d32a --- /dev/null +++ b/backend/src/plugins/Counters/commands/ViewCounterCmd.ts @@ -0,0 +1,111 @@ +import { guildCommand } from "knub"; +import { CountersPluginType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { resolveChannel, waitForReply } from "knub/dist/helpers"; +import { TextChannel, User } from "eris"; +import { resolveUser, UnknownUser } from "../../../utils"; + +export const ViewCounterCmd = guildCommand()({ + trigger: ["counters view", "counter view", "viewcounter", "counter"], + permission: "can_view", + + signature: [ + { + counterName: ct.string(), + }, + { + counterName: ct.string(), + user: ct.resolvedUser(), + }, + { + counterName: ct.string(), + channel: ct.textChannel(), + }, + { + counterName: ct.string(), + channel: ct.textChannel(), + user: ct.resolvedUser(), + }, + { + counterName: ct.string(), + user: ct.resolvedUser(), + channel: ct.textChannel(), + }, + ], + + async run({ pluginData, message, args }) { + const config = pluginData.config.getForMessage(message); + const counter = config.counters[args.counterName]; + const counterId = pluginData.state.counterIds[args.counterName]; + if (!counter || !counterId) { + sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + return; + } + + if (counter.can_view === false) { + sendErrorMessage(pluginData, message.channel, `Missing permissions to view this counter's value`); + return; + } + + if (args.channel && !counter.per_channel) { + sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + return; + } + + if (args.user && !counter.per_user) { + sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + return; + } + + let channel = args.channel; + if (!channel && counter.per_channel) { + message.channel.createMessage(`Which channel's counter value would you like to view?`); + const reply = await waitForReply(pluginData.client, message.channel, message.author.id); + if (!reply || !reply.content) { + sendErrorMessage(pluginData, message.channel, "Cancelling"); + return; + } + + const potentialChannel = resolveChannel(pluginData.guild, reply.content); + if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { + sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + return; + } + + channel = potentialChannel; + } + + let user = args.user; + if (!user && counter.per_user) { + message.channel.createMessage(`Which user's counter value would you like to view?`); + const reply = await waitForReply(pluginData.client, message.channel, message.author.id); + if (!reply || !reply.content) { + sendErrorMessage(pluginData, message.channel, "Cancelling"); + return; + } + + const potentialUser = await resolveUser(pluginData.client, reply.content); + if (!potentialUser || potentialUser instanceof UnknownUser) { + sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + return; + } + + user = potentialUser; + } + + const value = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null); + const finalValue = value ?? counter.initial_value; + const counterName = counter.name || args.counterName; + + if (channel && user) { + message.channel.createMessage(`**${counterName}** for <@!${user.id}> in <#${channel.id}> is ${finalValue}`); + } else if (channel) { + message.channel.createMessage(`**${counterName}** in <#${channel.id}> is ${finalValue}`); + } else if (user) { + message.channel.createMessage(`**${counterName}** for <@!${user.id}> is ${finalValue}`); + } else { + message.channel.createMessage(`**${counterName}** is ${finalValue}`); + } + }, +}); diff --git a/backend/src/plugins/Counters/functions/changeCounterValue.ts b/backend/src/plugins/Counters/functions/changeCounterValue.ts new file mode 100644 index 00000000..be214eb7 --- /dev/null +++ b/backend/src/plugins/Counters/functions/changeCounterValue.ts @@ -0,0 +1,48 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { checkCounterTrigger } from "./checkCounterTrigger"; +import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger"; + +export async function changeCounterValue( + pluginData: GuildPluginData, + counterName: string, + channelId: string | null, + userId: string | null, + change: number, +) { + const config = pluginData.config.get(); + const counter = config.counters[counterName]; + if (!counter) { + throw new Error(`Unknown counter: ${counterName}`); + } + + if (counter.per_channel && !channelId) { + throw new Error(`Counter is per channel but no channel ID was supplied`); + } + + if (counter.per_user && !userId) { + throw new Error(`Counter is per user but no user ID was supplied`); + } + + channelId = counter.per_channel ? channelId : null; + userId = counter.per_user ? userId : null; + + const counterId = pluginData.state.counterIds[counterName]; + const lock = await pluginData.locks.acquire(counterId.toString()); + + await pluginData.state.counters.changeCounterValue(counterId, channelId, userId, change); + + // Check for trigger matches, if any, when the counter value changes + const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); + if (triggers) { + const triggersArr = Array.from(triggers.values()); + await Promise.all( + triggersArr.map(trigger => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + ); + await Promise.all( + triggersArr.map(trigger => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + ); + } + + lock.unlock(); +} diff --git a/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts b/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts new file mode 100644 index 00000000..9336906e --- /dev/null +++ b/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts @@ -0,0 +1,23 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { buildConditionString } from "../../../data/GuildCounters"; +import { CounterTrigger } from "../../../data/entities/CounterTrigger"; +import { emitCounterEvent } from "./emitCounterEvent"; + +export async function checkAllValuesForReverseTrigger( + pluginData: GuildPluginData, + counterName: string, + counterTrigger: CounterTrigger, +) { + const triggeredContexts = await pluginData.state.counters.checkAllValuesForReverseTrigger(counterTrigger); + for (const context of triggeredContexts) { + emitCounterEvent( + pluginData, + "reverseTrigger", + counterName, + buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), + context.channelId, + context.userId, + ); + } +} diff --git a/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts b/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts new file mode 100644 index 00000000..277cff15 --- /dev/null +++ b/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts @@ -0,0 +1,23 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { buildConditionString } from "../../../data/GuildCounters"; +import { CounterTrigger } from "../../../data/entities/CounterTrigger"; +import { emitCounterEvent } from "./emitCounterEvent"; + +export async function checkAllValuesForTrigger( + pluginData: GuildPluginData, + counterName: string, + counterTrigger: CounterTrigger, +) { + const triggeredContexts = await pluginData.state.counters.checkAllValuesForTrigger(counterTrigger); + for (const context of triggeredContexts) { + emitCounterEvent( + pluginData, + "trigger", + counterName, + buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), + context.channelId, + context.userId, + ); + } +} diff --git a/backend/src/plugins/Counters/functions/checkCounterTrigger.ts b/backend/src/plugins/Counters/functions/checkCounterTrigger.ts new file mode 100644 index 00000000..e45cf28d --- /dev/null +++ b/backend/src/plugins/Counters/functions/checkCounterTrigger.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { buildConditionString } from "../../../data/GuildCounters"; +import { CounterTrigger } from "../../../data/entities/CounterTrigger"; +import { emitCounterEvent } from "./emitCounterEvent"; + +export async function checkCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + counterTrigger: CounterTrigger, + channelId: string | null, + userId: string | null, +) { + const triggered = await pluginData.state.counters.checkForTrigger(counterTrigger, channelId, userId); + if (triggered) { + await emitCounterEvent( + pluginData, + "trigger", + counterName, + buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), + channelId, + userId, + ); + } +} diff --git a/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts b/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts new file mode 100644 index 00000000..5ed9b3d7 --- /dev/null +++ b/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { buildConditionString } from "../../../data/GuildCounters"; +import { CounterTrigger } from "../../../data/entities/CounterTrigger"; +import { emitCounterEvent } from "./emitCounterEvent"; + +export async function checkReverseCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + counterTrigger: CounterTrigger, + channelId: string | null, + userId: string | null, +) { + const triggered = await pluginData.state.counters.checkForReverseTrigger(counterTrigger, channelId, userId); + if (triggered) { + await emitCounterEvent( + pluginData, + "reverseTrigger", + counterName, + buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), + channelId, + userId, + ); + } +} diff --git a/backend/src/plugins/Counters/functions/decayCounter.ts b/backend/src/plugins/Counters/functions/decayCounter.ts new file mode 100644 index 00000000..175cb158 --- /dev/null +++ b/backend/src/plugins/Counters/functions/decayCounter.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { checkAllValuesForTrigger } from "./checkAllValuesForTrigger"; +import { checkAllValuesForReverseTrigger } from "./checkAllValuesForReverseTrigger"; + +export async function decayCounter( + pluginData: GuildPluginData, + counterName: string, + decayPeriodMS: number, + decayAmount: number, +) { + const config = pluginData.config.get(); + const counter = config.counters[counterName]; + if (!counter) { + throw new Error(`Unknown counter: ${counterName}`); + } + + const counterId = pluginData.state.counterIds[counterName]; + const lock = await pluginData.locks.acquire(counterId.toString()); + + await pluginData.state.counters.decay(counterId, decayPeriodMS, decayAmount); + + // Check for trigger matches, if any, when the counter value changes + const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); + if (triggers) { + const triggersArr = Array.from(triggers.values()); + await Promise.all(triggersArr.map(trigger => checkAllValuesForTrigger(pluginData, counterName, trigger))); + await Promise.all(triggersArr.map(trigger => checkAllValuesForReverseTrigger(pluginData, counterName, trigger))); + } + + lock.unlock(); +} diff --git a/backend/src/plugins/Counters/functions/emitCounterEvent.ts b/backend/src/plugins/Counters/functions/emitCounterEvent.ts new file mode 100644 index 00000000..ad131e54 --- /dev/null +++ b/backend/src/plugins/Counters/functions/emitCounterEvent.ts @@ -0,0 +1,10 @@ +import { CounterEvents, CountersPluginType } from "../types"; +import { GuildPluginData } from "knub"; + +export function emitCounterEvent( + pluginData: GuildPluginData, + event: TEvent, + ...rest: Parameters +) { + return pluginData.state.events.emit(event, ...rest); +} diff --git a/backend/src/plugins/Counters/functions/initCounterTrigger.ts b/backend/src/plugins/Counters/functions/initCounterTrigger.ts new file mode 100644 index 00000000..afc5e9c9 --- /dev/null +++ b/backend/src/plugins/Counters/functions/initCounterTrigger.ts @@ -0,0 +1,31 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { parseCondition } from "../../../data/GuildCounters"; + +/** + * Initialize a counter trigger. + * After a counter trigger has been initialized, it will be checked against whenever the counter's values change. + * If the trigger is triggered, an event is emitted. + */ +export async function initCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + condition: string, +) { + const counterId = pluginData.state.counterIds[counterName]; + if (!counterId) { + throw new Error(`Unknown counter: ${counterName}`); + } + + const parsedComparison = parseCondition(condition); + if (!parsedComparison) { + throw new Error(`Invalid comparison string: ${condition}`); + } + + const [comparisonOp, comparisonValue] = parsedComparison; + const counterTrigger = await pluginData.state.counters.initCounterTrigger(counterId, comparisonOp, comparisonValue); + if (!pluginData.state.counterTriggersByCounterId.has(counterId)) { + pluginData.state.counterTriggersByCounterId.set(counterId, new Map()); + } + pluginData.state.counterTriggersByCounterId.get(counterId)!.set(counterTrigger.id, counterTrigger); +} diff --git a/backend/src/plugins/Counters/functions/offCounterEvent.ts b/backend/src/plugins/Counters/functions/offCounterEvent.ts new file mode 100644 index 00000000..66ee9624 --- /dev/null +++ b/backend/src/plugins/Counters/functions/offCounterEvent.ts @@ -0,0 +1,9 @@ +import { CounterEventEmitter, CountersPluginType } from "../types"; +import { GuildPluginData } from "knub"; + +export function offCounterEvent( + pluginData: GuildPluginData, + ...rest: Parameters +) { + return pluginData.state.events.off(...rest); +} diff --git a/backend/src/plugins/Counters/functions/onCounterEvent.ts b/backend/src/plugins/Counters/functions/onCounterEvent.ts new file mode 100644 index 00000000..1a3aa6fd --- /dev/null +++ b/backend/src/plugins/Counters/functions/onCounterEvent.ts @@ -0,0 +1,10 @@ +import { CounterEvents, CountersPluginType } from "../types"; +import { GuildPluginData } from "knub"; + +export function onCounterEvent( + pluginData: GuildPluginData, + event: TEvent, + listener: CounterEvents[TEvent], +) { + return pluginData.state.events.on(event, listener); +} diff --git a/backend/src/plugins/Counters/functions/setCounterValue.ts b/backend/src/plugins/Counters/functions/setCounterValue.ts new file mode 100644 index 00000000..2eefed8f --- /dev/null +++ b/backend/src/plugins/Counters/functions/setCounterValue.ts @@ -0,0 +1,45 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { checkCounterTrigger } from "./checkCounterTrigger"; +import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger"; + +export async function setCounterValue( + pluginData: GuildPluginData, + counterName: string, + channelId: string | null, + userId: string | null, + value: number, +) { + const config = pluginData.config.get(); + const counter = config.counters[counterName]; + if (!counter) { + throw new Error(`Unknown counter: ${counterName}`); + } + + if (counter.per_channel && !channelId) { + throw new Error(`Counter is per channel but no channel ID was supplied`); + } + + if (counter.per_user && !userId) { + throw new Error(`Counter is per user but no user ID was supplied`); + } + + const counterId = pluginData.state.counterIds[counterName]; + const lock = await pluginData.locks.acquire(counterId.toString()); + + await pluginData.state.counters.setCounterValue(counterId, channelId, userId, value); + + // Check for trigger matches, if any, when the counter value changes + const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); + if (triggers) { + const triggersArr = Array.from(triggers.values()); + await Promise.all( + triggersArr.map(trigger => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + ); + await Promise.all( + triggersArr.map(trigger => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + ); + } + + lock.unlock(); +} diff --git a/backend/src/plugins/Counters/functions/validateCondition.ts b/backend/src/plugins/Counters/functions/validateCondition.ts new file mode 100644 index 00000000..ca5e2e88 --- /dev/null +++ b/backend/src/plugins/Counters/functions/validateCondition.ts @@ -0,0 +1,8 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { parseCondition } from "../../../data/GuildCounters"; + +export function validateCondition(pluginData: GuildPluginData, condition: string) { + const parsed = parseCondition(condition); + return parsed != null; +} diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts new file mode 100644 index 00000000..3102e463 --- /dev/null +++ b/backend/src/plugins/Counters/types.ts @@ -0,0 +1,51 @@ +import * as t from "io-ts"; +import { BasePluginType } from "knub"; +import { GuildCounters } from "../../data/GuildCounters"; +import { tDelayString, tNullable } from "../../utils"; +import { EventEmitter } from "events"; +import { CounterTrigger } from "../../data/entities/CounterTrigger"; +import Timeout = NodeJS.Timeout; + +export const Counter = t.type({ + name: tNullable(t.string), + per_channel: t.boolean, + per_user: t.boolean, + initial_value: t.number, + decay: tNullable( + t.type({ + amount: t.number, + every: tDelayString, + }), + ), + can_view: tNullable(t.boolean), + can_edit: tNullable(t.boolean), +}); +export type TCounter = t.TypeOf; + +export const ConfigSchema = t.type({ + counters: t.record(t.string, Counter), + can_view: t.boolean, + can_edit: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface CounterEvents { + trigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void; + reverseTrigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void; +} + +export interface CounterEventEmitter extends EventEmitter { + on(event: U, listener: CounterEvents[U]): this; + emit(event: U, ...args: Parameters): boolean; +} + +export interface CountersPluginType extends BasePluginType { + config: TConfigSchema; + state: { + counters: GuildCounters; + counterIds: Record; + decayTimers: Timeout[]; + events: CounterEventEmitter; + counterTriggersByCounterId: Map>; + }; +} diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts index 2ed1e591..3173c507 100644 --- a/backend/src/plugins/Logs/LogsPlugin.ts +++ b/backend/src/plugins/Logs/LogsPlugin.ts @@ -31,7 +31,8 @@ const defaultOptions: PluginOptions = { timestamp: FORMAT_NO_TIMESTAMP, // Legacy/deprecated, use timestamp_format below instead ...DefaultLogMessages, }, - ping_user: true, + ping_user: true, // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true + allow_user_mentions: false, timestamp_format: "YYYY-MM-DD HH:mm:ss z", include_embed_timestamp: true, }, @@ -40,7 +41,7 @@ const defaultOptions: PluginOptions = { { level: ">=50", config: { - ping_user: false, + ping_user: false, // Legacy/deprecated, read comment on global ping_user option }, }, ], diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts index c44c5d88..34e4b3d9 100644 --- a/backend/src/plugins/Logs/types.ts +++ b/backend/src/plugins/Logs/types.ts @@ -38,7 +38,8 @@ export const ConfigSchema = t.type({ timestamp: t.string, // Legacy/deprecated }), ]), - ping_user: t.boolean, + ping_user: t.boolean, // Legacy/deprecated, if below is false mentions wont actually ping + allow_user_mentions: t.boolean, timestamp_format: t.string, include_embed_timestamp: t.boolean, }); diff --git a/backend/src/plugins/Logs/util/getLogMessage.ts b/backend/src/plugins/Logs/util/getLogMessage.ts index 818acfde..41fc369b 100644 --- a/backend/src/plugins/Logs/util/getLogMessage.ts +++ b/backend/src/plugins/Logs/util/getLogMessage.ts @@ -61,7 +61,12 @@ export async function getLogMessage( const memberConfig = pluginData.config.getMatchingConfig({ member, userId: user.id }) || ({} as any); - mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user)); + // Revert to old behavior (verbose name w/o ping if allow_user_mentions is enabled (for whatever reason)) + if (config.allow_user_mentions) { + mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user)); + } else { + mentions.push(verboseUserMention(user)); + } } return mentions.join(", "); diff --git a/backend/src/plugins/Logs/util/log.ts b/backend/src/plugins/Logs/util/log.ts index c38d8dda..aed24f3f 100644 --- a/backend/src/plugins/Logs/util/log.ts +++ b/backend/src/plugins/Logs/util/log.ts @@ -62,7 +62,7 @@ export async function log(pluginData: GuildPluginData, type: Log type === LogType.CENSOR || type === LogType.CLEAN ) { - if (data.channel.parent_id && opts.excluded_categories.includes(data.channel.parent_id)) { + if (data.channel.parentID && opts.excluded_categories.includes(data.channel.parentID)) { continue logChannelLoop; } } @@ -103,6 +103,7 @@ export async function log(pluginData: GuildPluginData, type: Log // Default to batched unless explicitly disabled const batched = opts.batched ?? true; const batchTime = opts.batch_time ?? 1000; + const cfg = pluginData.config.get(); if (batched) { // If we're batching log messages, gather all log messages within the set batch_time into a single message @@ -111,14 +112,14 @@ export async function log(pluginData: GuildPluginData, type: Log setTimeout(async () => { const batchedMessage = pluginData.state.batches.get(channel.id)!.join("\n"); pluginData.state.batches.delete(channel.id); - createChunkedMessage(channel, batchedMessage).catch(noop); + createChunkedMessage(channel, batchedMessage, { users: cfg.allow_user_mentions }).catch(noop); }, batchTime); } pluginData.state.batches.get(channel.id)!.push(message); } else { // If we're not batching log messages, just send them immediately - await createChunkedMessage(channel, message).catch(noop); + await createChunkedMessage(channel, message, { users: cfg.allow_user_mentions }).catch(noop); } } } diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index e091b038..672e4630 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -17,6 +17,7 @@ import { SoftbanCmd } from "./commands/SoftbanCommand"; import { BanCmd } from "./commands/BanCmd"; import { UnbanCmd } from "./commands/UnbanCmd"; import { ForcebanCmd } from "./commands/ForcebanCmd"; +import { MassunbanCmd } from "./commands/MassUnbanCmd"; import { MassbanCmd } from "./commands/MassBanCmd"; import { AddCaseCmd } from "./commands/AddCaseCmd"; import { CaseCmd } from "./commands/CaseCmd"; @@ -36,6 +37,12 @@ import { MassmuteCmd } from "./commands/MassmuteCmd"; import { trimPluginDescription } from "../../utils"; import { DeleteCaseCmd } from "./commands/DeleteCaseCmd"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; +import { GuildTempbans } from "../../data/GuildTempbans"; +import { outdatedTempbansLoop } from "./functions/outdatedTempbansLoop"; +import { EventEmitter } from "events"; +import { mapToPublicFn } from "../../pluginUtils"; +import { onModActionsEvent } from "./functions/onModActionsEvent"; +import { offModActionsEvent } from "./functions/offModActionsEvent"; const defaultOptions = { config: { @@ -49,6 +56,7 @@ const defaultOptions = { warn_message: "You have received a warning on the {guildName} server: {reason}", kick_message: "You have been kicked from the {guildName} server. Reason given: {reason}", ban_message: "You have been banned from the {guildName} server. Reason given: {reason}", + tempban_message: "You have been banned from the {guildName} server for {banTime}. Reason given: {reason}", alert_on_rejoin: false, alert_channel: null, warn_notify_enabled: false, @@ -64,6 +72,7 @@ const defaultOptions = { can_ban: false, can_view: false, can_addcase: false, + can_massunban: false, can_massban: false, can_massmute: false, can_hidecase: false, @@ -87,6 +96,7 @@ const defaultOptions = { { level: ">=100", config: { + can_massunban: true, can_massban: true, can_massmute: true, can_hidecase: true, @@ -131,6 +141,7 @@ export const ModActionsPlugin = zeppelinGuildPlugin()("mod ForcebanCmd, MassbanCmd, MassmuteCmd, + MassunbanCmd, AddCaseCmd, CaseCmd, CasesUserCmd, @@ -158,6 +169,12 @@ export const ModActionsPlugin = zeppelinGuildPlugin()("mod banUserId(pluginData, userId, reason, banOptions); }; }, + + on: mapToPublicFn(onModActionsEvent), + off: mapToPublicFn(offModActionsEvent), + getEventEmitter(pluginData) { + return () => pluginData.state.events; + }, }, onLoad(pluginData) { @@ -165,8 +182,20 @@ export const ModActionsPlugin = zeppelinGuildPlugin()("mod state.mutes = GuildMutes.getGuildInstance(guild.id); state.cases = GuildCases.getGuildInstance(guild.id); + state.tempbans = GuildTempbans.getGuildInstance(guild.id); state.serverLogs = new GuildLogs(guild.id); + state.unloaded = false; + state.outdatedTempbansTimeout = null; state.ignoredEvents = []; + + state.events = new EventEmitter(); + + outdatedTempbansLoop(pluginData); + }, + + onUnload(pluginData) { + pluginData.state.unloaded = true; + pluginData.state.events.removeAllListeners(); }, }); diff --git a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts index 9c965933..7bd06bf8 100644 --- a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts +++ b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts @@ -30,7 +30,8 @@ export const AddCaseCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } // If the user exists as a guild member, make sure we can act on them first diff --git a/backend/src/plugins/ModActions/commands/BanCmd.ts b/backend/src/plugins/ModActions/commands/BanCmd.ts index 4196aded..4fe9561e 100644 --- a/backend/src/plugins/ModActions/commands/BanCmd.ts +++ b/backend/src/plugins/ModActions/commands/BanCmd.ts @@ -1,14 +1,16 @@ import { modActionsCmd, IgnoredEventType } from "../types"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { canActOn, sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils"; -import { resolveUser, resolveMember } from "../../../utils"; +import { resolveUser, resolveMember, stripObjectToScalars, noop } from "../../../utils"; import { isBanned } from "../functions/isBanned"; import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; import { banUserId } from "../functions/banUserId"; -import { ignoreEvent } from "../functions/ignoreEvent"; -import { LogType } from "../../../data/LogType"; import { getMemberLevel, waitForReaction } from "knub/dist/helpers"; +import humanizeDuration from "humanize-duration"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; +import { CaseTypes } from "src/data/CaseTypes"; +import { LogType } from "src/data/LogType"; const opts = { mod: ct.member({ option: true }), @@ -20,9 +22,16 @@ const opts = { export const BanCmd = modActionsCmd({ trigger: "ban", permission: "can_ban", - description: "Ban the specified member", + description: "Ban or Tempban the specified member", signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), @@ -34,26 +43,93 @@ export const BanCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; + } + const time = args["time"] ? args["time"] : null; + + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + if (args.mod) { + if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { + sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + return; + } + + mod = args.mod; } - const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); - + // acquire a lock because of the needed user-inputs below (if banned/not on server) + const lock = await pluginData.locks.acquire(`ban-${user.id}`); let forceban = false; + const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); + const banned = await isBanned(pluginData, user.id); if (!memberToBan) { - const banned = await isBanned(pluginData, user.id); - if (banned) { - sendErrorMessage(pluginData, msg.channel, `User is already banned`); - return; + // Abort if trying to ban user indefinitely if they are already banned indefinitely + if (!existingTempban && !time) { + sendErrorMessage(pluginData, msg.channel, `User is already banned indefinitely.`); + return; + } + + // Ask the mod if we should update the existing ban + const alreadyBannedMsg = await msg.channel.createMessage("User is already banned, update ban?"); + const reply = await waitForReaction(pluginData.client, alreadyBannedMsg, ["✅", "❌"], msg.author.id); + + alreadyBannedMsg.delete().catch(noop); + if (!reply || reply.name === "❌") { + sendErrorMessage(pluginData, msg.channel, "User already banned, update cancelled by moderator"); + lock.unlock(); + return; + } else { + // Update or add new tempban / remove old tempban + if (time && time > 0) { + if (existingTempban) { + pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); + } else { + pluginData.state.tempbans.addTempban(user.id, time, mod.id); + } + } else if (existingTempban) { + pluginData.state.tempbans.clear(user.id); + } + + // Create a new case for the updated ban since we never stored the old case id and log the action + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + modId: mod.id, + type: CaseTypes.Ban, + userId: user.id, + reason, + noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`], + }); + const logtype = time ? LogType.MEMBER_TIMED_BAN : LogType.MEMBER_BAN; + pluginData.state.serverLogs.log(logtype, { + mod: stripObjectToScalars(mod.user), + user: stripObjectToScalars(user), + caseNumber: createdCase.case_number, + reason, + banTime: time ? humanizeDuration(time) : null, + }); + + sendSuccessMessage( + pluginData, + msg.channel, + `Ban updated to ${time ? "expire in " + humanizeDuration(time) + " from now" : "indefinite"}`, + ); + lock.unlock(); + return; + } } else { // Ask the mod if we should upgrade to a forceban as the user is not on the server const notOnServerMsg = await msg.channel.createMessage("User not found on the server, forceban instead?"); const reply = await waitForReaction(pluginData.client, notOnServerMsg, ["✅", "❌"], msg.author.id); - notOnServerMsg.delete(); + notOnServerMsg.delete().catch(noop); if (!reply || reply.name === "❌") { sendErrorMessage(pluginData, msg.channel, "User not on server, ban cancelled by moderator"); + lock.unlock(); return; } else { forceban = true; @@ -70,53 +146,62 @@ export const BanCmd = modActionsCmd({ msg.channel, `Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`, ); + lock.unlock(); return; } - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { - sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); - return; - } - - mod = args.mod; - } - let contactMethods; try { contactMethods = readContactMethodsFromArgs(args); } catch (e) { sendErrorMessage(pluginData, msg.channel, e.message); + lock.unlock(); return; } const deleteMessageDays = args["delete-days"] ?? pluginData.config.getForMessage(msg).ban_delete_message_days; - const reason = formatReasonWithAttachments(args.reason, msg.attachments); - const banResult = await banUserId(pluginData, user.id, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : undefined, + const banResult = await banUserId( + pluginData, + user.id, + reason, + { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== msg.author.id ? msg.author.id : undefined, + }, + deleteMessageDays, }, - deleteMessageDays, - }); + time, + ); if (banResult.status === "failed") { sendErrorMessage(pluginData, msg.channel, `Failed to ban member: ${banResult.error}`); + lock.unlock(); return; } + let forTime = ""; + if (time && time > 0) { + if (existingTempban) { + pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); + } else { + pluginData.state.tempbans.addTempban(user.id, time, mod.id); + } + + forTime = `for ${humanizeDuration(time)} `; + } + // Confirm the action to the moderator let response = ""; if (!forceban) { - response = `Banned **${user.username}#${user.discriminator}** (Case #${banResult.case.case_number})`; + response = `Banned **${user.username}#${user.discriminator}** ${forTime}(Case #${banResult.case.case_number})`; if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; } else { - response = `Member forcebanned (Case #${banResult.case.case_number})`; + response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`; } + lock.unlock(); sendSuccessMessage(pluginData, msg.channel, response); }, }); diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts index 916ac3a2..12fdf125 100644 --- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts +++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts @@ -37,7 +37,8 @@ export const CasesUserCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } const cases = await pluginData.state.cases.with("notes").getByUserId(user.id); diff --git a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts index c7f4e52c..907237ef 100644 --- a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts +++ b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts @@ -32,7 +32,8 @@ export const ForcebanCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } // If the user exists as a guild member, make sure we can act on them first @@ -66,6 +67,7 @@ export const ForcebanCmd = modActionsCmd({ pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); try { + // FIXME: Use banUserId()? await pluginData.guild.banMember(user.id, 1, reason != null ? encodeURIComponent(reason) : undefined); } catch (e) { sendErrorMessage(pluginData, msg.channel, "Failed to forceban member"); @@ -92,5 +94,7 @@ export const ForcebanCmd = modActionsCmd({ caseNumber: createdCase.case_number, reason, }); + + pluginData.state.events.emit("ban", user.id, reason); }, }); diff --git a/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts b/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts index 7c3e68b5..af680374 100644 --- a/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts @@ -34,7 +34,8 @@ export const ForcemuteCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); diff --git a/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts b/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts index 177676ab..e86a6408 100644 --- a/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts @@ -32,7 +32,8 @@ export const ForceUnmuteCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } // Check if they're muted in the first place diff --git a/backend/src/plugins/ModActions/commands/MassBanCmd.ts b/backend/src/plugins/ModActions/commands/MassBanCmd.ts index c4c1860f..bd79f7f3 100644 --- a/backend/src/plugins/ModActions/commands/MassBanCmd.ts +++ b/backend/src/plugins/ModActions/commands/MassBanCmd.ts @@ -75,6 +75,8 @@ export const MassbanCmd = modActionsCmd({ reason: `Mass ban: ${banReason}`, postInCaseLogOverride: false, }); + + pluginData.state.events.emit("ban", userId, banReason); } catch (e) { failedBans.push(userId); } diff --git a/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts b/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts new file mode 100644 index 00000000..897cc25f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts @@ -0,0 +1,125 @@ +import { modActionsCmd, IgnoredEventType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { stripObjectToScalars } from "../../../utils"; +import { isBanned } from "../functions/isBanned"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { TextChannel } from "eris"; +import { waitForReply } from "knub/dist/helpers"; +import { ignoreEvent } from "../functions/ignoreEvent"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { LogType } from "../../../data/LogType"; + +export const MassunbanCmd = modActionsCmd({ + trigger: "massunban", + permission: "can_massunban", + description: "Mass-unban a list of user IDs", + + signature: [ + { + userIds: ct.string({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + // Limit to 100 users at once (arbitrary?) + if (args.userIds.length > 100) { + sendErrorMessage(pluginData, msg.channel, `Can only mass-unban max 100 users at once`); + return; + } + + // Ask for unban reason (cleaner this way instead of trying to cram it into the args) + msg.channel.createMessage("Unban reason? `cancel` to cancel"); + const unbanReasonReply = await waitForReply(pluginData.client, msg.channel as TextChannel, msg.author.id); + if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") { + sendErrorMessage(pluginData, msg.channel, "Cancelled"); + return; + } + + const unbanReason = formatReasonWithAttachments(unbanReasonReply.content, msg.attachments); + + // Ignore automatic unban cases and logs for these users + // We'll create our own cases below and post a single "mass unbanned" log instead + args.userIds.forEach(userId => { + // Use longer timeouts since this can take a while + ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 120 * 1000); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 120 * 1000); + }); + + // Show a loading indicator since this can take a while + const loadingMsg = await msg.channel.createMessage("Unbanning..."); + + // Unban each user and count failed unbans (if any) + const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = []; + const casesPlugin = pluginData.getPlugin(CasesPlugin); + for (const userId of args.userIds) { + if (!(await isBanned(pluginData, userId))) { + failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED }); + continue; + } + + try { + await pluginData.guild.unbanMember(userId, unbanReason != null ? encodeURIComponent(unbanReason) : undefined); + + await casesPlugin.createCase({ + userId, + modId: msg.author.id, + type: CaseTypes.Unban, + reason: `Mass unban: ${unbanReason}`, + postInCaseLogOverride: false, + }); + } catch (e) { + failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED }); + } + } + + // Clear loading indicator + loadingMsg.delete(); + + const successfulUnbanCount = args.userIds.length - failedUnbans.length; + if (successfulUnbanCount === 0) { + // All unbans failed - don't create a log entry and notify the user + sendErrorMessage(pluginData, msg.channel, "All unbans failed. Make sure the IDs are valid and banned."); + } else { + // Some or all unbans were successful. Create a log entry for the mass unban and notify the user. + pluginData.state.serverLogs.log(LogType.MASSUNBAN, { + mod: stripObjectToScalars(msg.author), + count: successfulUnbanCount, + reason: unbanReason, + }); + + if (failedUnbans.length) { + const notBanned = failedUnbans.filter(x => x.reason === UnbanFailReasons.NOT_BANNED); + const unbanFailed = failedUnbans.filter(x => x.reason === UnbanFailReasons.UNBAN_FAILED); + + let failedMsg = ""; + if (notBanned.length > 0) { + failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`; + notBanned.forEach(fail => { + failedMsg += " " + fail.userId; + }); + } + if (unbanFailed.length > 0) { + failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`; + unbanFailed.forEach(fail => { + failedMsg += " " + fail.userId; + }); + } + + sendSuccessMessage( + pluginData, + msg.channel, + `Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`, + ); + } else { + sendSuccessMessage(pluginData, msg.channel, `Unbanned ${successfulUnbanCount} users successfully`); + } + } + }, +}); + +enum UnbanFailReasons { + NOT_BANNED = "Not banned", + UNBAN_FAILED = "Unban failed", +} diff --git a/backend/src/plugins/ModActions/commands/MuteCmd.ts b/backend/src/plugins/ModActions/commands/MuteCmd.ts index 709dbbb7..ada23e2f 100644 --- a/backend/src/plugins/ModActions/commands/MuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/MuteCmd.ts @@ -44,7 +44,8 @@ export const MuteCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); diff --git a/backend/src/plugins/ModActions/commands/NoteCmd.ts b/backend/src/plugins/ModActions/commands/NoteCmd.ts index 1c8b3478..af6a1298 100644 --- a/backend/src/plugins/ModActions/commands/NoteCmd.ts +++ b/backend/src/plugins/ModActions/commands/NoteCmd.ts @@ -15,13 +15,19 @@ export const NoteCmd = modActionsCmd({ signature: { user: ct.string(), - note: ct.string({ catchAll: true }), + note: ct.string({ required: false, catchAll: true }), }, async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; + } + + if (!args.note && msg.attachments.length === 0) { + sendErrorMessage(pluginData, msg.channel, "Text or attachment required"); + return; } const userName = `${user.username}#${user.discriminator}`; @@ -43,5 +49,7 @@ export const NoteCmd = modActionsCmd({ }); sendSuccessMessage(pluginData, msg.channel, `Note added on **${userName}** (Case #${createdCase.case_number})`); + + pluginData.state.events.emit("note", user.id, reason); }, }); diff --git a/backend/src/plugins/ModActions/commands/UnbanCmd.ts b/backend/src/plugins/ModActions/commands/UnbanCmd.ts index 9db1f85d..80cac4f2 100644 --- a/backend/src/plugins/ModActions/commands/UnbanCmd.ts +++ b/backend/src/plugins/ModActions/commands/UnbanCmd.ts @@ -29,7 +29,8 @@ export const UnbanCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } // The moderator who did the action is the message author or, if used, the specified -mod @@ -63,6 +64,8 @@ export const UnbanCmd = modActionsCmd({ reason, ppId: mod.id !== msg.author.id ? msg.author.id : undefined, }); + // Delete the tempban, if one exists + pluginData.state.tempbans.clear(user.id); // Confirm the action sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); diff --git a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts index eac1bdb8..f6efdd39 100644 --- a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts @@ -34,7 +34,8 @@ export const UnmuteCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); diff --git a/backend/src/plugins/ModActions/commands/WarnCmd.ts b/backend/src/plugins/ModActions/commands/WarnCmd.ts index 0329103b..8ac19baa 100644 --- a/backend/src/plugins/ModActions/commands/WarnCmd.ts +++ b/backend/src/plugins/ModActions/commands/WarnCmd.ts @@ -30,7 +30,8 @@ export const WarnCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id); @@ -111,5 +112,7 @@ export const WarnCmd = modActionsCmd({ msg.channel, `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`, ); + + pluginData.state.events.emit("warn", user.id, reason); }, }); diff --git a/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts b/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts index 931b7985..ca3faed8 100644 --- a/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts @@ -69,5 +69,7 @@ export const CreateBanCaseOnManualBanEvt = modActionsEvt( caseNumber: createdCase?.case_number ?? 0, reason, }); + + pluginData.state.events.emit("ban", user.id, reason); }, ); diff --git a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts index a88ba02d..dd353ac9 100644 --- a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts @@ -62,6 +62,8 @@ export const CreateKickCaseOnManualKickEvt = modActionsEvt( mod: mod ? stripObjectToScalars(mod) : null, caseNumber: createdCase?.case_number ?? 0, }); + + pluginData.state.events.emit("kick", member.id, kickAuditLogEntry.reason || undefined); } }, ); diff --git a/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts b/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts index e41e87a5..d7bb1c88 100644 --- a/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts @@ -66,5 +66,7 @@ export const CreateUnbanCaseOnManualUnbanEvt = modActionsEvt( userId: user.id, caseNumber: createdCase?.case_number ?? 0, }); + + pluginData.state.events.emit("unban", user.id); }, ); diff --git a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts index 1df90eef..18ee2137 100644 --- a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts +++ b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts @@ -25,7 +25,8 @@ export async function actualKickMemberCmd( ) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - return sendErrorMessage(pluginData, msg.channel, `User not found`); + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; } const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id); diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts index 74c0357b..85d05de9 100644 --- a/backend/src/plugins/ModActions/functions/banUserId.ts +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -16,6 +16,7 @@ import { ignoreEvent } from "./ignoreEvent"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CaseTypes } from "../../../data/CaseTypes"; import { logger } from "../../../logger"; +import humanizeDuration from "humanize-duration"; /** * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. @@ -25,6 +26,7 @@ export async function banUserId( userId: string, reason?: string, banOptions: BanOptions = {}, + banTime?: number, ): Promise { const config = pluginData.config.get(); const user = await resolveUser(pluginData.client, userId); @@ -43,7 +45,7 @@ export async function banUserId( : getDefaultContactMethods(pluginData, "ban"); if (contactMethods.length) { - if (config.ban_message) { + if (!banTime && config.ban_message) { const banMessage = await renderTemplate(config.ban_message, { guildName: pluginData.guild.name, reason, @@ -52,9 +54,20 @@ export async function banUserId( : {}, }); + notifyResult = await notifyUser(user, banMessage, contactMethods); + } else if (banTime && config.tempban_message) { + const banMessage = await renderTemplate(config.tempban_message, { + guildName: pluginData.guild.name, + reason, + moderator: banOptions.caseArgs?.modId + ? stripObjectToScalars(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) + : {}, + banTime: humanizeDuration(banTime), + }); + notifyResult = await notifyUser(user, banMessage, contactMethods); } else { - notifyResult = createUserNotificationError("No ban message specified in config"); + notifyResult = createUserNotificationError("No ban/tempban message specified in config"); } } } @@ -87,24 +100,35 @@ export async function banUserId( // Create a case for this action const modId = banOptions.caseArgs?.modId || pluginData.client.user.id; const casesPlugin = pluginData.getPlugin(CasesPlugin); + + const noteDetails: string[] = []; + const timeUntilUnban = banTime ? humanizeDuration(banTime) : "indefinite"; + const timeDetails = `Banned ${banTime ? `for ${timeUntilUnban}` : "indefinitely"}`; + if (notifyResult.text) noteDetails.push(ucfirst(notifyResult.text)); + noteDetails.push(timeDetails); + const createdCase = await casesPlugin.createCase({ ...(banOptions.caseArgs || {}), userId, modId, type: CaseTypes.Ban, reason, - noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], + noteDetails, }); // Log the action const mod = await resolveUser(pluginData.client, modId); - pluginData.state.serverLogs.log(LogType.MEMBER_BAN, { + const logtype = banTime ? LogType.MEMBER_TIMED_BAN : LogType.MEMBER_BAN; + pluginData.state.serverLogs.log(logtype, { mod: stripObjectToScalars(mod), user: stripObjectToScalars(user), caseNumber: createdCase.case_number, reason, + banTime: banTime ? humanizeDuration(banTime) : null, }); + pluginData.state.events.emit("ban", user.id, reason); + return { status: "success", case: createdCase, diff --git a/backend/src/plugins/ModActions/functions/kickMember.ts b/backend/src/plugins/ModActions/functions/kickMember.ts index 16ac2ecc..9e297af8 100644 --- a/backend/src/plugins/ModActions/functions/kickMember.ts +++ b/backend/src/plugins/ModActions/functions/kickMember.ts @@ -85,6 +85,8 @@ export async function kickMember( reason, }); + pluginData.state.events.emit("kick", member.id, reason); + return { status: "success", case: createdCase, diff --git a/backend/src/plugins/ModActions/functions/offModActionsEvent.ts b/backend/src/plugins/ModActions/functions/offModActionsEvent.ts new file mode 100644 index 00000000..798fa733 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/offModActionsEvent.ts @@ -0,0 +1,10 @@ +import { GuildPluginData } from "knub"; +import { ModActionsEvents, ModActionsPluginType } from "../types"; + +export function offModActionsEvent( + pluginData: GuildPluginData, + event: TEvent, + listener: ModActionsEvents[TEvent], +) { + return pluginData.state.events.off(event, listener); +} diff --git a/backend/src/plugins/ModActions/functions/onModActionsEvent.ts b/backend/src/plugins/ModActions/functions/onModActionsEvent.ts new file mode 100644 index 00000000..e1219bad --- /dev/null +++ b/backend/src/plugins/ModActions/functions/onModActionsEvent.ts @@ -0,0 +1,10 @@ +import { GuildPluginData } from "knub"; +import { ModActionsEvents, ModActionsPluginType } from "../types"; + +export function onModActionsEvent( + pluginData: GuildPluginData, + event: TEvent, + listener: ModActionsEvents[TEvent], +) { + return pluginData.state.events.on(event, listener); +} diff --git a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts b/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts new file mode 100644 index 00000000..0fc3749c --- /dev/null +++ b/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts @@ -0,0 +1,67 @@ +import { resolveUser, SECONDS, stripObjectToScalars } from "../../../utils"; +import { GuildPluginData } from "knub"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; +import { LogType } from "src/data/LogType"; +import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; +import { ignoreEvent } from "./ignoreEvent"; +import { isBanned } from "./isBanned"; +import { logger } from "src/logger"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; +import moment from "moment-timezone"; +import humanizeDuration from "humanize-duration"; + +const TEMPBAN_LOOP_TIME = 60 * SECONDS; + +export async function outdatedTempbansLoop(pluginData: GuildPluginData) { + const outdatedTempbans = await pluginData.state.tempbans.getExpiredTempbans(); + + for (const tempban of outdatedTempbans) { + if (!(await isBanned(pluginData, tempban.user_id))) { + pluginData.state.tempbans.clear(tempban.user_id); + continue; + } + + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id); + const reason = formatReasonWithAttachments( + `Tempban timed out. + Tempbanned at: \`${tempban.created_at} UTC\``, + [], + ); + try { + ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id); + await pluginData.guild.unbanMember(tempban.user_id, reason != null ? encodeURIComponent(reason) : undefined); + } catch (e) { + pluginData.state.serverLogs.log(LogType.BOT_ALERT, { + body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`, + }); + logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`); + return; + } + + // Create case and delete tempban + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: tempban.user_id, + modId: tempban.mod_id, + type: CaseTypes.Unban, + reason, + ppId: undefined, + }); + pluginData.state.tempbans.clear(tempban.user_id); + + // Log the unban + const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at)); + pluginData.state.serverLogs.log(LogType.MEMBER_TIMED_UNBAN, { + mod: stripObjectToScalars(await resolveUser(pluginData.client, tempban.mod_id)), + userId: tempban.user_id, + caseNumber: createdCase.case_number, + reason, + banTime: humanizeDuration(banTime), + }); + } + + if (!pluginData.state.unloaded) { + pluginData.state.outdatedTempbansTimeout = setTimeout(() => outdatedTempbansLoop(pluginData), TEMPBAN_LOOP_TIME); + } +} diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index a6cd96c4..af4fd8d6 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -7,6 +7,9 @@ import { GuildLogs } from "../../data/GuildLogs"; import { Case } from "../../data/entities/Case"; import { CaseArgs } from "../Cases/types"; import { TextChannel } from "eris"; +import { GuildTempbans } from "../../data/GuildTempbans"; +import Timeout = NodeJS.Timeout; +import { EventEmitter } from "events"; export const ConfigSchema = t.type({ dm_on_warn: t.boolean, @@ -19,6 +22,7 @@ export const ConfigSchema = t.type({ warn_message: tNullable(t.string), kick_message: tNullable(t.string), ban_message: tNullable(t.string), + tempban_message: tNullable(t.string), alert_on_rejoin: t.boolean, alert_channel: tNullable(t.string), warn_notify_enabled: t.boolean, @@ -32,6 +36,7 @@ export const ConfigSchema = t.type({ can_ban: t.boolean, can_view: t.boolean, can_addcase: t.boolean, + can_massunban: t.boolean, can_massban: t.boolean, can_massmute: t.boolean, can_hidecase: t.boolean, @@ -41,14 +46,33 @@ export const ConfigSchema = t.type({ }); export type TConfigSchema = t.TypeOf; +export interface ModActionsEvents { + note: (userId: string, reason?: string) => void; + warn: (userId: string, reason?: string) => void; + kick: (userId: string, reason?: string) => void; + ban: (userId: string, reason?: string) => void; + unban: (userId: string, reason?: string) => void; + // mute/unmute are in the Mutes plugin +} + +export interface ModActionsEventEmitter extends EventEmitter { + on(event: U, listener: ModActionsEvents[U]): this; + emit(event: U, ...args: Parameters): boolean; +} + export interface ModActionsPluginType extends BasePluginType { config: TConfigSchema; state: { mutes: GuildMutes; cases: GuildCases; + tempbans: GuildTempbans; serverLogs: GuildLogs; + unloaded: boolean; + outdatedTempbansTimeout: Timeout | null; ignoredEvents: IIgnoredEvent[]; + + events: ModActionsEventEmitter; }; } @@ -115,5 +139,7 @@ export interface BanOptions { deleteMessageDays?: number; } +export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban"; + export const modActionsCmd = guildCommand(); export const modActionsEvt = guildEventListener(); diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index d580325e..ac55acc6 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -13,11 +13,13 @@ import { ClearMutesWithoutRoleCmd } from "./commands/ClearMutesWithoutRoleCmd"; import { ClearMutesCmd } from "./commands/ClearMutesCmd"; import { muteUser } from "./functions/muteUser"; import { unmuteUser } from "./functions/unmuteUser"; -import { CaseArgs } from "../Cases/types"; import { Member } from "eris"; import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt"; import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt"; import { mapToPublicFn } from "../../pluginUtils"; +import { EventEmitter } from "events"; +import { onMutesEvent } from "./functions/onMutesEvent"; +import { offMutesEvent } from "./functions/offMutesEvent"; const defaultOptions = { config: { @@ -32,6 +34,8 @@ const defaultOptions = { mute_message: "You have been muted on the {guildName} server. Reason given: {reason}", timed_mute_message: "You have been muted on the {guildName} server for {time}. Reason given: {reason}", update_mute_message: "Your mute on the {guildName} server has been updated to {time}.", + remove_roles_on_mute: false, + restore_roles_on_mute: false, can_view_list: false, can_cleanup: false, @@ -91,6 +95,12 @@ export const MutesPlugin = zeppelinGuildPlugin()("mutes", { return muteRole ? member.roles.includes(muteRole) : false; }; }, + + on: mapToPublicFn(onMutesEvent), + off: mapToPublicFn(offMutesEvent), + getEventEmitter(pluginData) { + return () => pluginData.state.events; + }, }, onLoad(pluginData) { @@ -99,6 +109,8 @@ export const MutesPlugin = zeppelinGuildPlugin()("mutes", { pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id); pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id); + pluginData.state.events = new EventEmitter(); + // Check for expired mutes every 5s const firstCheckTime = Math.max(Date.now(), FIRST_CHECK_TIME) + FIRST_CHECK_INCREMENT; FIRST_CHECK_TIME = firstCheckTime; @@ -114,5 +126,6 @@ export const MutesPlugin = zeppelinGuildPlugin()("mutes", { onUnload(pluginData) { clearInterval(pluginData.state.muteClearIntervalId); + pluginData.state.events.removeAllListeners(); }, }); diff --git a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts index ae5b7e04..e99f2b73 100644 --- a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts +++ b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts @@ -2,6 +2,7 @@ import { GuildPluginData } from "knub"; import { MutesPluginType } from "../types"; import { LogType } from "../../../data/LogType"; import { resolveMember, stripObjectToScalars, UnknownUser } from "../../../utils"; +import { MemberOptions } from "eris"; export async function clearExpiredMutes(pluginData: GuildPluginData) { const expiredMutes = await pluginData.state.mutes.getExpiredMutes(); @@ -14,6 +15,14 @@ export async function clearExpiredMutes(pluginData: GuildPluginData x !== muteRole && guildRoles.has(x))]), + ); + member.edit(memberOptions); + } } catch (e) { pluginData.state.serverLogs.log(LogType.BOT_ALERT, { body: `Failed to remove mute role from {userMention(member)}`, @@ -29,5 +38,7 @@ export async function clearExpiredMutes(pluginData: GuildPluginData, @@ -24,6 +26,8 @@ export async function muteUser( muteTime?: number, reason?: string, muteOptions: MuteOptions = {}, + removeRolesOnMuteOverride: boolean | string[] | null = null, + restoreRolesOnMuteOverride: boolean | string[] | null = null, ) { const lock = await pluginData.locks.acquire(`mute-${userId}`); @@ -50,10 +54,69 @@ export async function muteUser( const member = await resolveMember(pluginData.client, pluginData.guild, user.id, true); // Grab the fresh member so we don't have stale role info const config = pluginData.config.getMatchingConfig({ member, userId }); + let rolesToRestore: string[] = []; if (member) { + const logs = pluginData.getPlugin(LogsPlugin); + // remove and store any roles to be removed/restored + const currentUserRoles = member.roles; + const memberOptions: MemberOptions = {}; + const removeRoles = removeRolesOnMuteOverride ?? config.remove_roles_on_mute; + const restoreRoles = restoreRolesOnMuteOverride ?? config.restore_roles_on_mute; + + // remove roles + if (!Array.isArray(removeRoles)) { + if (removeRoles) { + // exclude managed roles from being removed + const managedRoles = pluginData.guild.roles.filter(x => x.managed).map(y => y.id); + memberOptions.roles = managedRoles.filter(x => member.roles.includes(x)); + await member.edit(memberOptions); + } + } else { + memberOptions.roles = currentUserRoles.filter(x => !(removeRoles).includes(x)); + await member.edit(memberOptions); + } + + // set roles to be restored + if (!Array.isArray(restoreRoles)) { + if (restoreRoles) { + rolesToRestore = currentUserRoles; + } + } else { + rolesToRestore = currentUserRoles.filter(x => (restoreRoles).includes(x)); + } + // Apply mute role if it's missing if (!member.roles.includes(muteRole)) { - await member.addRole(muteRole); + try { + await member.addRole(muteRole); + } catch (e) { + const actualMuteRole = pluginData.guild.roles.find(x => x.id === muteRole); + if (!actualMuteRole) { + lock.unlock(); + logs.log(LogType.BOT_ALERT, { + body: `Cannot mute users, specified mute role Id is invalid`, + }); + throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID); + } + + const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user.id); + const zepRoles = pluginData.guild.roles.filter(x => zep!.roles.includes(x.id)); + // If we have roles and one of them is above the muted role, throw generic error + if (zepRoles.length >= 0 && zepRoles.some(zepRole => zepRole.position > actualMuteRole.position)) { + lock.unlock(); + logs.log(LogType.BOT_ALERT, { + body: `Cannot mute user ${member.id}: ${e}`, + }); + throw e; + } else { + // Otherwise, throw error that mute role is above zeps roles + lock.unlock(); + logs.log(LogType.BOT_ALERT, { + body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`, + }); + throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP); + } + } } // If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role) @@ -71,9 +134,12 @@ export async function muteUser( let notifyResult: UserNotificationResult = { method: null, success: true }; if (existingMute) { - await pluginData.state.mutes.updateExpiryTime(user.id, muteTime); + if (existingMute.roles_to_restore?.length || rolesToRestore?.length) { + rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore])); + } + await pluginData.state.mutes.updateExpiryTime(user.id, muteTime, rolesToRestore); } else { - await pluginData.state.mutes.addMute(user.id, muteTime); + await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore); } const template = existingMute @@ -180,6 +246,8 @@ export async function muteUser( lock.unlock(); + pluginData.state.events.emit("mute", user.id, reason); + return { case: theCase, notifyResult, diff --git a/backend/src/plugins/Mutes/functions/offMutesEvent.ts b/backend/src/plugins/Mutes/functions/offMutesEvent.ts new file mode 100644 index 00000000..e48f0d46 --- /dev/null +++ b/backend/src/plugins/Mutes/functions/offMutesEvent.ts @@ -0,0 +1,10 @@ +import { GuildPluginData } from "knub"; +import { MutesEvents, MutesPluginType } from "../types"; + +export function offMutesEvent( + pluginData: GuildPluginData, + event: TEvent, + listener: MutesEvents[TEvent], +) { + return pluginData.state.events.off(event, listener); +} diff --git a/backend/src/plugins/Mutes/functions/onMutesEvent.ts b/backend/src/plugins/Mutes/functions/onMutesEvent.ts new file mode 100644 index 00000000..71f13ba4 --- /dev/null +++ b/backend/src/plugins/Mutes/functions/onMutesEvent.ts @@ -0,0 +1,10 @@ +import { GuildPluginData } from "knub"; +import { MutesEvents, MutesPluginType } from "../types"; + +export function onMutesEvent( + pluginData: GuildPluginData, + event: TEvent, + listener: MutesEvents[TEvent], +) { + return pluginData.state.events.on(event, listener); +} diff --git a/backend/src/plugins/Mutes/functions/unmuteUser.ts b/backend/src/plugins/Mutes/functions/unmuteUser.ts index ac85a726..dce5cc06 100644 --- a/backend/src/plugins/Mutes/functions/unmuteUser.ts +++ b/backend/src/plugins/Mutes/functions/unmuteUser.ts @@ -7,7 +7,7 @@ import humanizeDuration from "humanize-duration"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; -import { WithRequiredProps } from "../../../utils/typeUtils"; +import { MemberOptions } from "eris"; export async function unmuteUser( pluginData: GuildPluginData, @@ -36,7 +36,16 @@ export async function unmuteUser( if (muteRole && member.roles.includes(muteRole)) { await member.removeRole(muteRole); } + if (existingMute?.roles_to_restore) { + const memberOptions: MemberOptions = {}; + const guildRoles = pluginData.guild.roles; + memberOptions.roles = Array.from( + new Set([...existingMute.roles_to_restore, ...member.roles.filter(x => x !== muteRole && guildRoles.has(x))]), + ); + member.edit(memberOptions); + } } else { + // tslint:disable-next-line:no-console console.warn( `Member ${userId} not found in guild ${pluginData.guild.name} (${pluginData.guild.id}) when attempting to unmute`, ); @@ -87,6 +96,12 @@ export async function unmuteUser( }); } + if (!unmuteTime) { + // If the member was unmuted, not just scheduled to be unmuted, fire the unmute event as well + // Scheduled unmutes have their event fired in clearExpiredMutes() + pluginData.state.events.emit("unmute", user.id, caseArgs.reason); + } + return { case: createdCase, }; diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index 8fe7eb55..72ea52c2 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -10,6 +10,7 @@ import { GuildArchives } from "../../data/GuildArchives"; import { GuildMutes } from "../../data/GuildMutes"; import { CaseArgs } from "../Cases/types"; import Timeout = NodeJS.Timeout; +import { EventEmitter } from "events"; export const ConfigSchema = t.type({ mute_role: tNullable(t.string), @@ -23,12 +24,24 @@ export const ConfigSchema = t.type({ mute_message: tNullable(t.string), timed_mute_message: tNullable(t.string), update_mute_message: tNullable(t.string), + remove_roles_on_mute: t.union([t.boolean, t.array(t.string)]), + restore_roles_on_mute: t.union([t.boolean, t.array(t.string)]), can_view_list: t.boolean, can_cleanup: t.boolean, }); export type TConfigSchema = t.TypeOf; +export interface MutesEvents { + mute: (userId: string, reason?: string) => void; + unmute: (userId: string, reason?: string) => void; +} + +export interface MutesEventEmitter extends EventEmitter { + on(event: U, listener: MutesEvents[U]): this; + emit(event: U, ...args: Parameters): boolean; +} + export interface MutesPluginType extends BasePluginType { config: TConfigSchema; state: { @@ -38,6 +51,8 @@ export interface MutesPluginType extends BasePluginType { archives: GuildArchives; muteClearIntervalId: Timeout; + + events: MutesEventEmitter; }; } diff --git a/backend/src/plugins/NameHistory/commands/NamesCmd.ts b/backend/src/plugins/NameHistory/commands/NamesCmd.ts index dfb1f3cb..78456820 100644 --- a/backend/src/plugins/NameHistory/commands/NamesCmd.ts +++ b/backend/src/plugins/NameHistory/commands/NamesCmd.ts @@ -20,7 +20,8 @@ export const NamesCmd = nameHistoryCmd({ const usernames = await pluginData.state.usernameHistory.getByUserId(args.userId); if (nicknames.length === 0 && usernames.length === 0) { - return sendErrorMessage(pluginData, msg.channel, "No name history found"); + sendErrorMessage(pluginData, msg.channel, "No name history found"); + return; } const nicknameRows = nicknames.map( diff --git a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts b/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts index cc5222ec..98810fc1 100644 --- a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts +++ b/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts @@ -16,7 +16,8 @@ export const ScheduledPostsDeleteCmd = postCmd({ scheduledPosts.sort(sorter("post_at")); const post = scheduledPosts[args.num - 1]; if (!post) { - return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + return; } await pluginData.state.scheduledPosts.delete(post.id); diff --git a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts index 0e76f134..25ecdede 100644 --- a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts +++ b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts @@ -18,7 +18,8 @@ export const ScheduledPostsShowCmd = postCmd({ scheduledPosts.sort(sorter("post_at")); const post = scheduledPosts[args.num - 1]; if (!post) { - return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + return; } postMessage(pluginData, msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions); diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts index 5c74aa7f..b8c8cbc8 100644 --- a/backend/src/plugins/Post/util/actualPostCmd.ts +++ b/backend/src/plugins/Post/util/actualPostCmd.ts @@ -39,14 +39,12 @@ export async function actualPostCmd( if (opts.repeat) { if (opts.repeat < MIN_REPEAT_TIME) { - return sendErrorMessage( - pluginData, - msg.channel, - `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`, - ); + sendErrorMessage(pluginData, msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`); + return; } if (opts.repeat > MAX_REPEAT_TIME) { - return sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); + sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); + return; } } @@ -56,7 +54,8 @@ export async function actualPostCmd( // Schedule the post to be posted later postAt = await parseScheduleTime(pluginData, msg.author.id, opts.schedule); if (!postAt) { - return sendErrorMessage(pluginData, msg.channel, "Invalid schedule time"); + sendErrorMessage(pluginData, msg.channel, "Invalid schedule time"); + return; } } else if (opts.repeat) { postAt = moment.utc().add(opts.repeat, "ms"); @@ -72,35 +71,37 @@ export async function actualPostCmd( // Invalid time if (!repeatUntil) { - return sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until"); + sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until"); + return; } if (repeatUntil.isBefore(moment.utc())) { - return sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past"); + sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past"); + return; } if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { - return sendErrorMessage( + sendErrorMessage( pluginData, msg.channel, "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", ); + return; } } else if (opts["repeat-times"]) { repeatTimes = opts["repeat-times"]; if (repeatTimes <= 0) { - return sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more"); + sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more"); + return; } } if (repeatUntil && repeatTimes) { - return sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); + sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); + return; } if (opts.repeat && !repeatUntil && !repeatTimes) { - return sendErrorMessage( - pluginData, - msg.channel, - "You must specify -repeat-until or -repeat-times for repeated messages", - ); + sendErrorMessage(pluginData, msg.channel, "You must specify -repeat-until or -repeat-times for repeated messages"); + return; } if (opts.repeat) { @@ -114,7 +115,8 @@ export async function actualPostCmd( // Save schedule/repeat information in DB if (postAt) { if (postAt < moment.utc()) { - return sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past"); + sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past"); + return; } await pluginData.state.scheduledPosts.create({ diff --git a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts index 17c2e067..119b7b27 100644 --- a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts @@ -3,7 +3,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { TextChannel } from "eris"; import { RecoverablePluginError, ERRORS } from "../../../RecoverablePluginError"; -import { canUseEmoji, isDiscordRESTError, isValidEmoji, noop } from "../../../utils"; +import { canUseEmoji, isDiscordRESTError, isValidEmoji, noop, trimPluginDescription } from "../../../utils"; import { applyReactionRoleReactionsToMessage } from "../util/applyReactionRoleReactionsToMessage"; import { canReadChannel } from "../../../utils/canReadChannel"; @@ -12,6 +12,19 @@ const CLEAR_ROLES_EMOJI = "❌"; export const InitReactionRolesCmd = reactionRolesCmd({ trigger: "reaction_roles", permission: "can_manage", + description: trimPluginDescription(` + This command allows you to add reaction roles to a given message. + The basic usage is as follows: + + !reaction_roles 800865377520582687 + 👍 = 556110793058287637 + 👎 = 558037973581430785 + + A reactionRolePair is any emoji the bot can use, an equal sign and the role id it should correspond to. + Every pair needs to be in its own line for the command to work properly. + If the message you specify is not found, use \`!save_messages_to_db \` + to manually add it to the stored messages database permanently. + `), signature: { message: ct.messageTarget(), diff --git a/backend/src/plugins/Roles/commands/AddRoleCmd.ts b/backend/src/plugins/Roles/commands/AddRoleCmd.ts index a17042c9..20395f27 100644 --- a/backend/src/plugins/Roles/commands/AddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/AddRoleCmd.ts @@ -17,17 +17,20 @@ export const AddRoleCmd = rolesCmd({ async run({ message: msg, args, pluginData }) { if (!canActOn(pluginData, msg.member, args.member, true)) { - return sendErrorMessage(pluginData, msg.channel, "Cannot add roles to this user: insufficient permissions"); + sendErrorMessage(pluginData, msg.channel, "Cannot add roles to this user: insufficient permissions"); + return; } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + return; } const config = pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + return; } // Sanity check: make sure the role is configured properly @@ -36,11 +39,13 @@ export const AddRoleCmd = rolesCmd({ pluginData.state.logs.log(LogType.BOT_ALERT, { body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + return; } if (args.member.roles.includes(roleId)) { - return sendErrorMessage(pluginData, msg.channel, "Member already has that role"); + sendErrorMessage(pluginData, msg.channel, "Member already has that role"); + return; } pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); diff --git a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts index 4f0f458c..b2e3000f 100644 --- a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts @@ -28,22 +28,25 @@ export const MassAddRoleCmd = rolesCmd({ for (const member of members) { if (!canActOn(pluginData, msg.member, member, true)) { - return sendErrorMessage( + sendErrorMessage( pluginData, msg.channel, "Cannot add roles to 1 or more specified members: insufficient permissions", ); + return; } } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + return; } const config = pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + return; } const role = pluginData.guild.roles.get(roleId); @@ -51,7 +54,8 @@ export const MassAddRoleCmd = rolesCmd({ pluginData.state.logs.log(LogType.BOT_ALERT, { body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + return; } const membersWithoutTheRole = members.filter(m => !m.roles.includes(roleId)); diff --git a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts index 593e4c07..966f2e4a 100644 --- a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts @@ -28,22 +28,25 @@ export const MassRemoveRoleCmd = rolesCmd({ for (const member of members) { if (!canActOn(pluginData, msg.member, member, true)) { - return sendErrorMessage( + sendErrorMessage( pluginData, msg.channel, "Cannot add roles to 1 or more specified members: insufficient permissions", ); + return; } } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + return; } const config = pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - return sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + return; } const role = pluginData.guild.roles.get(roleId); @@ -51,7 +54,8 @@ export const MassRemoveRoleCmd = rolesCmd({ pluginData.state.logs.log(LogType.BOT_ALERT, { body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - return sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + return; } const membersWithTheRole = members.filter(m => m.roles.includes(roleId)); diff --git a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts index f3af983b..d078e1f6 100644 --- a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts @@ -17,17 +17,20 @@ export const RemoveRoleCmd = rolesCmd({ async run({ message: msg, args, pluginData }) { if (!canActOn(pluginData, msg.member, args.member, true)) { - return sendErrorMessage(pluginData, msg.channel, "Cannot remove roles from this user: insufficient permissions"); + sendErrorMessage(pluginData, msg.channel, "Cannot remove roles from this user: insufficient permissions"); + return; } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + return; } const config = pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - return sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + return; } // Sanity check: make sure the role is configured properly @@ -36,11 +39,13 @@ export const RemoveRoleCmd = rolesCmd({ pluginData.state.logs.log(LogType.BOT_ALERT, { body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - return sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + return; } if (!args.member.roles.includes(roleId)) { - return sendErrorMessage(pluginData, msg.channel, "Member doesn't have that role"); + sendErrorMessage(pluginData, msg.channel, "Member doesn't have that role"); + return; } pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts index 1da1dc29..f237bc97 100644 --- a/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts +++ b/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts @@ -126,11 +126,8 @@ export const SlowmodeSetCmd = slowmodeCmd({ rateLimitPerUser: rateLimitSeconds, }); } catch (e) { - return sendErrorMessage( - pluginData, - msg.channel, - `Failed to set native slowmode: ${disableInlineCode(e.message)}`, - ); + sendErrorMessage(pluginData, msg.channel, `Failed to set native slowmode: ${disableInlineCode(e.message)}`); + return; } } else { // If there is an existing native slowmode, disable that first diff --git a/backend/src/plugins/Spam/types.ts b/backend/src/plugins/Spam/types.ts index 127a1e2c..9f34a3b3 100644 --- a/backend/src/plugins/Spam/types.ts +++ b/backend/src/plugins/Spam/types.ts @@ -11,6 +11,8 @@ const BaseSingleSpamConfig = t.type({ count: t.number, mute: tNullable(t.boolean), mute_time: tNullable(t.number), + remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), + restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), clean: tNullable(t.boolean), }); export type TBaseSingleSpamConfig = t.TypeOf; diff --git a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts index 63edc594..d5cc946e 100644 --- a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts +++ b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts @@ -82,12 +82,19 @@ export async function logAndDetectMessageSpam( (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000; try { - muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", { - caseArgs: { - modId: pluginData.client.user.id, - postInCaseLogOverride: false, + muteResult = await mutesPlugin.muteUser( + member.id, + muteTime, + "Automatic spam detection", + { + caseArgs: { + modId: pluginData.client.user.id, + postInCaseLogOverride: false, + }, }, - }); + spamConfig.remove_roles_on_mute, + spamConfig.restore_roles_on_mute, + ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { logs.log(LogType.BOT_ALERT, { diff --git a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts index ee904218..b2fcb236 100644 --- a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts +++ b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts @@ -41,12 +41,19 @@ export async function logAndDetectOtherSpam( (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000; try { - await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", { - caseArgs: { - modId: pluginData.client.user.id, - extraNotes: [`Details: ${details}`], + await mutesPlugin.muteUser( + member.id, + muteTime, + "Automatic spam detection", + { + caseArgs: { + modId: pluginData.client.user.id, + extraNotes: [`Details: ${details}`], + }, }, - }); + spamConfig.remove_roles_on_mute, + spamConfig.restore_roles_on_mute, + ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { logs.log(LogType.BOT_ALERT, { diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts index 83f00116..e8bb8541 100644 --- a/backend/src/plugins/Tags/TagsPlugin.ts +++ b/backend/src/plugins/Tags/TagsPlugin.ts @@ -19,6 +19,7 @@ import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { mapToPublicFn } from "../../pluginUtils"; import { renderTagBody } from "./util/renderTagBody"; import { findTagByName } from "./util/findTagByName"; +import { StrictValidationError } from "src/validatorUtils"; const defaultOptions: PluginOptions = { config: { @@ -29,6 +30,7 @@ const defaultOptions: PluginOptions = { global_tag_cooldown: null, user_cooldown: null, global_cooldown: null, + auto_delete_command: false, categories: {}, @@ -71,6 +73,28 @@ export const TagsPlugin = zeppelinGuildPlugin()("tags", { findTagByName: mapToPublicFn(findTagByName), }, + configPreprocessor(options) { + if (options.config.delete_with_command && options.config.auto_delete_command) { + throw new StrictValidationError([ + `Cannot have both (global) delete_with_command and global_delete_invoke enabled`, + ]); + } + + // Check each category for conflicting options + if (options.config?.categories) { + for (const [name, opts] of Object.entries(options.config.categories)) { + const cat = options.config.categories[name]; + if (cat.delete_with_command && cat.auto_delete_command) { + throw new StrictValidationError([ + `Cannot have both (category specific) delete_with_command and category_delete_invoke enabled at `, + ]); + } + } + } + + return options; + }, + onLoad(pluginData) { const { state, guild } = pluginData; @@ -178,7 +202,7 @@ export const TagsPlugin = zeppelinGuildPlugin()("tags", { return ""; } - if (input.match(/^<(@#)(!&)\d+>$/)) { + if (input.match(/^<(?:@(?:!|&)?|#)\d+>$/)) { return input; } diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts index f22e0eac..15b0ce5b 100644 --- a/backend/src/plugins/Tags/types.ts +++ b/backend/src/plugins/Tags/types.ts @@ -16,6 +16,7 @@ export const TagCategory = t.type({ user_category_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag category global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag global_category_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per category + auto_delete_command: tNullable(t.boolean), // Any tag, per tag category tags: t.record(t.string, Tag), @@ -31,6 +32,7 @@ export const ConfigSchema = t.type({ global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag user_cooldown: tNullable(t.union([t.string, t.number])), // Per user global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use + auto_delete_command: t.boolean, // Any tag categories: t.record(t.string, TagCategory), diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts index e2dbb961..d7d8530b 100644 --- a/backend/src/plugins/Tags/util/onMessageCreate.ts +++ b/backend/src/plugins/Tags/util/onMessageCreate.ts @@ -1,7 +1,7 @@ import { TagsPluginType } from "../types"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { GuildPluginData } from "knub"; -import { convertDelayStringToMS, resolveMember, tStrictMessageContent } from "../../../utils"; +import { convertDelayStringToMS, noop, resolveMember, tStrictMessageContent } from "../../../utils"; import { validate } from "../../../validatorUtils"; import { LogType } from "../../../data/LogType"; import { TextChannel } from "eris"; @@ -108,4 +108,10 @@ export async function onMessageCreate(pluginData: GuildPluginData = { config: { @@ -53,6 +54,7 @@ const defaultOptions: PluginOptions = { can_ping: false, can_source: false, can_vcmove: false, + can_vckick: false, can_help: false, can_about: false, can_context: false, @@ -78,6 +80,7 @@ const defaultOptions: PluginOptions = { can_snowflake: true, can_nickname: true, can_vcmove: true, + can_vckick: true, can_help: true, can_context: true, can_jumbo: true, @@ -113,13 +116,14 @@ export const UtilityPlugin = zeppelinGuildPlugin()("utility", UserInfoCmd, LevelCmd, RolesCmd, - ServerCmd, + ServerInfoCmd, NicknameResetCmd, NicknameCmd, PingCmd, SourceCmd, ContextCmd, VcmoveCmd, + VcdisconnectCmd, VcmoveAllCmd, HelpCmd, AboutCmd, @@ -148,7 +152,7 @@ export const UtilityPlugin = zeppelinGuildPlugin()("utility", state.lastReload = Date.now(); if (activeReloads.has(guild.id)) { - sendSuccessMessage(pluginData, activeReloads.get(guild.id), "Reloaded!"); + sendSuccessMessage(pluginData, activeReloads.get(guild.id)!, "Reloaded!"); activeReloads.delete(guild.id); } }, diff --git a/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts b/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts index c65ab41d..044ba63d 100644 --- a/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts @@ -7,7 +7,7 @@ export const ChannelInfoCmd = utilityCmd({ trigger: ["channel", "channelinfo"], description: "Show information about a channel", usage: "!channel 534722016549404673", - permission: "can_channel", + permission: "can_channelinfo", signature: { channel: ct.channelId({ required: false }), diff --git a/backend/src/plugins/Utility/commands/CleanCmd.ts b/backend/src/plugins/Utility/commands/CleanCmd.ts index a54964f1..e75da2f8 100644 --- a/backend/src/plugins/Utility/commands/CleanCmd.ts +++ b/backend/src/plugins/Utility/commands/CleanCmd.ts @@ -64,6 +64,7 @@ export const CleanCmd = utilityCmd({ "delete-pins": ct.switchOption({ shortcut: "p" }), "has-invites": ct.switchOption({ shortcut: "i" }), match: ct.regex({ option: true, shortcut: "m" }), + "to-id": ct.anyId({ option: true, shortcut: "id" }), }, async run({ message: msg, args, pluginData }) { @@ -96,6 +97,8 @@ export const CleanCmd = utilityCmd({ const messagesToClean: SavedMessage[] = []; let beforeId = msg.id; const timeCutoff = msg.timestamp - MAX_CLEAN_TIME; + const upToMsgId = args["to-id"]; + let foundId = false; const deletePins = args["delete-pins"] != null ? args["delete-pins"] : false; let pins: Message[] = []; @@ -118,6 +121,10 @@ export const CleanCmd = utilityCmd({ if (args.bots && !message.is_bot) continue; if (!deletePins && pins.find(x => x.id === message.id) != null) continue; if (args["has-invites"] && getInviteCodesInString(contentString).length === 0) continue; + if (upToMsgId != null && message.id < upToMsgId) { + foundId = true; + break; + } if (moment.utc(message.posted_at).valueOf() < timeCutoff) continue; if (args.match && !(await pluginData.state.regexRunner.exec(args.match, contentString).catch(allowTimeout))) { continue; @@ -131,12 +138,15 @@ export const CleanCmd = utilityCmd({ beforeId = potentialMessagesToClean[potentialMessagesToClean.length - 1].id; - if (moment.utc(potentialMessagesToClean[potentialMessagesToClean.length - 1].posted_at).valueOf() < timeCutoff) { + if ( + foundId || + moment.utc(potentialMessagesToClean[potentialMessagesToClean.length - 1].posted_at).valueOf() < timeCutoff + ) { break; } } - let responseMsg: Message; + let responseMsg: Message | undefined; if (messagesToClean.length > 0) { const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, msg.author); @@ -157,7 +167,7 @@ export const CleanCmd = utilityCmd({ // (so as not to spam the cleaned channel with the command itself) setTimeout(() => { msg.delete().catch(noop); - responseMsg.delete().catch(noop); + responseMsg?.delete().catch(noop); }, CLEAN_COMMAND_DELETE_DELAY); } }, diff --git a/backend/src/plugins/Utility/commands/ContextCmd.ts b/backend/src/plugins/Utility/commands/ContextCmd.ts index 4cbfefc1..81a488b3 100644 --- a/backend/src/plugins/Utility/commands/ContextCmd.ts +++ b/backend/src/plugins/Utility/commands/ContextCmd.ts @@ -27,8 +27,8 @@ export const ContextCmd = utilityCmd({ return; } - const channel = args.channel || args.message.channel; - const messageId = args.messageId || args.message.messageId; + const channel = args.channel ?? args.message.channel; + const messageId = args.messageId ?? args.message.messageId; if (!canReadChannel(channel, msg.member)) { sendErrorMessage(pluginData, msg.channel, "Message context not found"); diff --git a/backend/src/plugins/Utility/commands/InfoCmd.ts b/backend/src/plugins/Utility/commands/InfoCmd.ts index 12af56e3..10fc500a 100644 --- a/backend/src/plugins/Utility/commands/InfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InfoCmd.ts @@ -27,11 +27,16 @@ export const InfoCmd = utilityCmd({ async run({ message, args, pluginData }) { const value = args.value || message.author.id; + const userCfg = pluginData.config.getMatchingConfig({ + member: message.member, + channelId: message.channel.id, + message, + }); // 1. Channel const channelId = getChannelId(value); const channel = channelId && pluginData.guild.channels.get(channelId); - if (channel) { + if (channel && userCfg.can_channelinfo) { const embed = await getChannelInfoEmbed(pluginData, channelId!, message.author.id); if (embed) { message.channel.createMessage({ embed }); @@ -41,7 +46,7 @@ export const InfoCmd = utilityCmd({ // 2. Server const guild = pluginData.client.guilds.get(value); - if (guild) { + if (guild && userCfg.can_server) { const embed = await getServerInfoEmbed(pluginData, value, message.author.id); if (embed) { message.channel.createMessage({ embed }); @@ -51,7 +56,7 @@ export const InfoCmd = utilityCmd({ // 3. User const user = await resolveUser(pluginData.client, value); - if (user) { + if (user && userCfg.can_userinfo) { const embed = await getUserInfoEmbed(pluginData, user.id, Boolean(args.compact), message.author.id); if (embed) { message.channel.createMessage({ embed }); @@ -61,7 +66,7 @@ export const InfoCmd = utilityCmd({ // 4. Message const messageTarget = await resolveMessageTarget(pluginData, value); - if (messageTarget) { + if (messageTarget && userCfg.can_messageinfo) { if (canReadChannel(messageTarget.channel, message.member)) { const embed = await getMessageInfoEmbed( pluginData, @@ -77,10 +82,10 @@ export const InfoCmd = utilityCmd({ } // 5. Invite - const inviteCode = await parseInviteCodeInput(value); + const inviteCode = parseInviteCodeInput(value) ?? value; if (inviteCode) { const invite = await resolveInvite(pluginData.client, inviteCode, true); - if (invite) { + if (invite && userCfg.can_inviteinfo) { const embed = await getInviteInfoEmbed(pluginData, inviteCode); if (embed) { message.channel.createMessage({ embed }); @@ -91,7 +96,7 @@ export const InfoCmd = utilityCmd({ // 6. Server again (fallback for discovery servers) const serverPreview = getGuildPreview(pluginData.client, value).catch(() => null); - if (serverPreview) { + if (serverPreview && userCfg.can_server) { const embed = await getServerInfoEmbed(pluginData, value, message.author.id); if (embed) { message.channel.createMessage({ embed }); @@ -100,13 +105,17 @@ export const InfoCmd = utilityCmd({ } // 7. Arbitrary ID - if (isValidSnowflake(value)) { + if (isValidSnowflake(value) && userCfg.can_snowflake) { const embed = await getSnowflakeInfoEmbed(pluginData, value, true, message.author.id); message.channel.createMessage({ embed }); return; } // 7. No can do - sendErrorMessage(pluginData, message.channel, "Could not find anything with that value"); + sendErrorMessage( + pluginData, + message.channel, + "Could not find anything with that value or you are lacking permission for the snowflake type", + ); }, }); diff --git a/backend/src/plugins/Utility/commands/InviteInfoCmd.ts b/backend/src/plugins/Utility/commands/InviteInfoCmd.ts index 4121bfc0..0406ff84 100644 --- a/backend/src/plugins/Utility/commands/InviteInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InviteInfoCmd.ts @@ -8,7 +8,7 @@ export const InviteInfoCmd = utilityCmd({ trigger: ["invite", "inviteinfo"], description: "Show information about an invite", usage: "!invite overwatch", - permission: "can_invite", + permission: "can_inviteinfo", signature: { inviteCode: ct.string({ required: false }), diff --git a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts index 2d694d24..4681fd2e 100644 --- a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts @@ -8,7 +8,7 @@ export const MessageInfoCmd = utilityCmd({ trigger: ["message", "messageinfo"], description: "Show information about a message", usage: "!message 534722016549404673-534722219696455701", - permission: "can_message", + permission: "can_messageinfo", signature: { message: ct.messageTarget(), diff --git a/backend/src/plugins/Utility/commands/NicknameResetCmd.ts b/backend/src/plugins/Utility/commands/NicknameResetCmd.ts index 6e954743..e1d74ba0 100644 --- a/backend/src/plugins/Utility/commands/NicknameResetCmd.ts +++ b/backend/src/plugins/Utility/commands/NicknameResetCmd.ts @@ -14,7 +14,7 @@ export const NicknameResetCmd = utilityCmd({ }, async run({ message: msg, args, pluginData }) { - if (msg.member.id !== args.member.id && canActOn(pluginData, msg.member, args.member)) { + if (msg.member.id !== args.member.id && !canActOn(pluginData, msg.member, args.member)) { msg.channel.createMessage(errorMessage("Cannot reset nickname: insufficient permissions")); return; } diff --git a/backend/src/plugins/Utility/commands/ServerCmd.ts b/backend/src/plugins/Utility/commands/ServerInfoCmd.ts similarity index 95% rename from backend/src/plugins/Utility/commands/ServerCmd.ts rename to backend/src/plugins/Utility/commands/ServerInfoCmd.ts index 2792b821..97a5d06a 100644 --- a/backend/src/plugins/Utility/commands/ServerCmd.ts +++ b/backend/src/plugins/Utility/commands/ServerInfoCmd.ts @@ -3,7 +3,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage } from "../../../pluginUtils"; import { getServerInfoEmbed } from "../functions/getServerInfoEmbed"; -export const ServerCmd = utilityCmd({ +export const ServerInfoCmd = utilityCmd({ trigger: ["server", "serverinfo"], description: "Show server information", usage: "!server", diff --git a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts new file mode 100644 index 00000000..0d5e6925 --- /dev/null +++ b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts @@ -0,0 +1,58 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { + channelMentionRegex, + errorMessage, + isSnowflake, + simpleClosestStringMatch, + stripObjectToScalars, +} from "../../../utils"; +import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { VoiceChannel } from "eris"; +import { LogType } from "../../../data/LogType"; +import { resolveChannel } from "knub/dist/helpers"; + +export const VcdisconnectCmd = utilityCmd({ + trigger: ["vcdisconnect", "vcdisc", "vcdc", "vckick", "vck"], + description: "Disconnect a member from their voice channel", + usage: "!vcdc @Dark", + permission: "can_vckick", + + signature: { + member: ct.resolvedMember(), + }, + + async run({ message: msg, args, pluginData }) { + if (!canActOn(pluginData, msg.member, args.member)) { + sendErrorMessage(pluginData, msg.channel, "Cannot move: insufficient permissions"); + return; + } + + if (!args.member.voiceState || !args.member.voiceState.channelID) { + sendErrorMessage(pluginData, msg.channel, "Member is not in a voice channel"); + return; + } + const channel = (await resolveChannel(pluginData.guild, args.member.voiceState.channelID)) as VoiceChannel; + + try { + await args.member.edit({ + channelID: null, + }); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, "Failed to disconnect member"); + return; + } + + pluginData.state.logs.log(LogType.VOICE_CHANNEL_FORCE_DISCONNECT, { + mod: stripObjectToScalars(msg.author), + member: stripObjectToScalars(args.member, ["user", "roles"]), + oldChannel: stripObjectToScalars(channel), + }); + + sendSuccessMessage( + pluginData, + msg.channel, + `**${args.member.user.username}#${args.member.user.discriminator}** disconnected from **${channel.name}**`, + ); + }, +}); diff --git a/backend/src/plugins/Utility/commands/VcmoveCmd.ts b/backend/src/plugins/Utility/commands/VcmoveCmd.ts index 4d82c0d0..aa9a9131 100644 --- a/backend/src/plugins/Utility/commands/VcmoveCmd.ts +++ b/backend/src/plugins/Utility/commands/VcmoveCmd.ts @@ -26,8 +26,6 @@ export const VcmoveCmd = utilityCmd({ async run({ message: msg, args, pluginData }) { let channel: VoiceChannel; - const foo = args.member; - if (isSnowflake(args.channel)) { // Snowflake -> resolve channel directly const potentialChannel = pluginData.guild.channels.get(args.channel); @@ -78,7 +76,7 @@ export const VcmoveCmd = utilityCmd({ channelID: channel.id, }); } catch (e) { - msg.channel.createMessage(errorMessage("Failed to move member")); + sendErrorMessage(pluginData, msg.channel, "Failed to move member"); return; } diff --git a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts index 4486d5f5..3022a250 100644 --- a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts @@ -80,6 +80,32 @@ export async function getServerInfoEmbed( value: basicInformation.join("\n"), }); + // IMAGE LINKS + const iconUrl = `[URL](${(restGuild || guildPreview)!.iconURL})`; + const bannerUrl = restGuild?.bannerURL ?? "Unavailable"; + const splashUrl = + (restGuild || guildPreview)!.splashURL != null + ? `[URL](${(restGuild || guildPreview)!.splashURL?.replace("size=128", "size=2048")})` + : "None"; + + embed.fields.push( + { + name: "Server icon", + value: iconUrl, + inline: true, + }, + { + name: "Invite splash", + value: splashUrl, + inline: true, + }, + { + name: "Server banner", + value: bannerUrl, + inline: true, + }, + ); + // MEMBER COUNTS const totalMembers = guildPreview?.approximateMemberCount || diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index ca3829dd..c369e7ba 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -83,18 +83,21 @@ export async function displaySearch( } } catch (e) { if (e instanceof SearchError) { - return sendErrorMessage(pluginData, msg.channel, e.message); + sendErrorMessage(pluginData, msg.channel, e.message); + return; } if (e instanceof InvalidRegexError) { - return sendErrorMessage(pluginData, msg.channel, e.message); + sendErrorMessage(pluginData, msg.channel, e.message); + return; } throw e; } if (searchResult.totalResults === 0) { - return sendErrorMessage(pluginData, msg.channel, "No results found"); + sendErrorMessage(pluginData, msg.channel, "No results found"); + return; } const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members"; @@ -203,18 +206,21 @@ export async function archiveSearch( } } catch (e) { if (e instanceof SearchError) { - return sendErrorMessage(pluginData, msg.channel, e.message); + sendErrorMessage(pluginData, msg.channel, e.message); + return; } if (e instanceof InvalidRegexError) { - return sendErrorMessage(pluginData, msg.channel, e.message); + sendErrorMessage(pluginData, msg.channel, e.message); + return; } throw e; } if (results.totalResults === 0) { - return sendErrorMessage(pluginData, msg.channel, "No results found"); + sendErrorMessage(pluginData, msg.channel, "No results found"); + return; } const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results); diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts index 710fa788..8d6de6b4 100644 --- a/backend/src/plugins/Utility/types.ts +++ b/backend/src/plugins/Utility/types.ts @@ -24,6 +24,7 @@ export const ConfigSchema = t.type({ can_ping: t.boolean, can_source: t.boolean, can_vcmove: t.boolean, + can_vckick: t.boolean, can_help: t.boolean, can_about: t.boolean, can_context: t.boolean, diff --git a/backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts b/backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts index cbd04093..c807c14c 100644 --- a/backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts +++ b/backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts @@ -26,8 +26,11 @@ export const SendWelcomeMessageEvt = welcomeMessageEvt({ let formatted; try { + const strippedMember = stripObjectToScalars(member, ["user", "guild"]); formatted = await renderTemplate(config.message, { - member: stripObjectToScalars(member, ["user"]), + member: strippedMember, + user: strippedMember["user"], + guild: strippedMember["guild"], }); } catch (e) { if (e instanceof TemplateParseError) { diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 5777e343..e1e4e936 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -32,6 +32,7 @@ import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin"; import { BotControlPlugin } from "./BotControl/BotControlPlugin"; import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin"; import { TimeAndDatePlugin } from "./TimeAndDate/TimeAndDatePlugin"; +import { CountersPlugin } from "./Counters/CountersPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -65,6 +66,7 @@ export const guildPlugins: Array> = [ CompanionChannelsPlugin, CustomEventsPlugin, TimeAndDatePlugin, + CountersPlugin, ]; // prettier-ignore diff --git a/backend/src/utils.ts b/backend/src/utils.ts index fb7c3c7d..b78a29c7 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,4 +1,5 @@ import { + AllowedMentions, Attachment, Client, Constants, @@ -748,10 +749,14 @@ export function chunkMessageLines(str: string, maxChunkLength = 1990): string[] }); } -export async function createChunkedMessage(channel: TextableChannel, messageText: string) { +export async function createChunkedMessage( + channel: TextableChannel, + messageText: string, + allowedMentions?: AllowedMentions, +) { const chunks = chunkMessageLines(messageText); for (const chunk of chunks) { - await channel.createMessage(chunk); + await channel.createMessage({ content: chunk, allowedMentions }); } } @@ -1178,7 +1183,7 @@ export async function confirm(bot: Client, channel: TextableChannel, userId: str export function messageSummary(msg: SavedMessage) { // Regular text content - let result = "```" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "") + "```"; + let result = "```\n" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "") + "```"; // Rich embed const richEmbed = (msg.data.embeds || []).find(e => (e as Embed).type === "rich"); diff --git a/backend/src/utils/registerEventListenersFromMap.ts b/backend/src/utils/registerEventListenersFromMap.ts new file mode 100644 index 00000000..d2a4cf8c --- /dev/null +++ b/backend/src/utils/registerEventListenersFromMap.ts @@ -0,0 +1,7 @@ +import { EventEmitter } from "events"; + +export function registerEventListenersFromMap(eventEmitter: EventEmitter, map: Map) { + for (const [event, listener] of map.entries()) { + eventEmitter.on(event, listener); + } +} diff --git a/backend/src/utils/unregisterEventListenersFromMap.ts b/backend/src/utils/unregisterEventListenersFromMap.ts new file mode 100644 index 00000000..3aacb165 --- /dev/null +++ b/backend/src/utils/unregisterEventListenersFromMap.ts @@ -0,0 +1,7 @@ +import { EventEmitter } from "events"; + +export function unregisterEventListenersFromMap(eventEmitter: EventEmitter, map: Map) { + for (const [event, listener] of map.entries()) { + eventEmitter.off(event, listener); + } +} diff --git a/presetup-configurator/package-lock.json b/presetup-configurator/package-lock.json new file mode 100644 index 00000000..37cd3f41 --- /dev/null +++ b/presetup-configurator/package-lock.json @@ -0,0 +1,305 @@ +{ + "name": "zeppelin-presetup-configurator", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@snowpack/plugin-typescript": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@snowpack/plugin-typescript/-/plugin-typescript-1.2.1.tgz", + "integrity": "sha512-wU+JNaMVkqGsqTaUY7TnEMhGt/3URTgA9dpMCtZX6wn/ceA7Gwlmue/sOLynf0OTNLygHPvjiQECQYkEi3LTtg==", + "dev": true, + "requires": { + "execa": "^5.0.0", + "npm-run-path": "^4.0.1" + } + }, + "@types/node": { + "version": "14.14.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.21.tgz", + "integrity": "sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A==", + "dev": true + }, + "@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", + "dev": true + }, + "@types/react": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.0.tgz", + "integrity": "sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-dom": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.0.tgz", + "integrity": "sha512-lUqY7OlkF/RbNtD5nIq7ot8NquXrdFrjSOR6+w9a9RFQevGi1oZO1dcJbXMeONAPKtZ2UrZOEJ5UOCVsxbLk/g==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "csstype": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", + "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==", + "dev": true + }, + "esbuild": { + "version": "0.8.32", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.32.tgz", + "integrity": "sha512-5IzQapMW/wFy5oxziHCJzawk26K3xeyrIAQPnPN3c0Q84hqRw6IfGDGfGWOdJNw5tAx77yvwqZ4r1QMpo6emJA==", + "dev": true + }, + "execa": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz", + "integrity": "sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "fsevents": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.1.tgz", + "integrity": "sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw==", + "dev": true, + "optional": true + }, + "get-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", + "integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==", + "dev": true + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, + "is-docker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.1.1.tgz", + "integrity": "sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw==", + "dev": true + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz", + "integrity": "sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==", + "requires": { + "argparse": "^2.0.1" + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "open": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/open/-/open-7.3.1.tgz", + "integrity": "sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A==", + "dev": true, + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "react": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz", + "integrity": "sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "react-dom": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz", + "integrity": "sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.1" + } + }, + "rollup": { + "version": "2.36.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.36.2.tgz", + "integrity": "sha512-qjjiuJKb+/8n0EZyQYVW+gFU4bNRBcZaXVzUgSVrGw0HlQBlK2aWyaOMMs1Ufic1jV69b9kW3u3i9B+hISDm3A==", + "dev": true, + "requires": { + "fsevents": "~2.1.2" + }, + "dependencies": { + "fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "dev": true, + "optional": true + } + } + }, + "scheduler": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.1.tgz", + "integrity": "sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "snowpack": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/snowpack/-/snowpack-3.0.11.tgz", + "integrity": "sha512-lBxgkvWTgdg0szE31JUt01wQkA9Lnmm+6lxqeV9rxDfflpx7ASnldVHFvu7Se70QJmPTQB0UJjfKI+xmYGwiiQ==", + "dev": true, + "requires": { + "esbuild": "^0.8.7", + "fsevents": "^2.2.0", + "open": "^7.0.4", + "rollup": "^2.34.0" + } + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } +} diff --git a/presetup-configurator/package.json b/presetup-configurator/package.json new file mode 100644 index 00000000..837348c2 --- /dev/null +++ b/presetup-configurator/package.json @@ -0,0 +1,21 @@ +{ + "name": "zeppelin-presetup-configurator", + "private": true, + "scripts": { + "watch": "snowpack dev", + "build": "snowpack build", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "devDependencies": { + "@snowpack/plugin-typescript": "^1.2.1", + "@types/node": "^14.14.21", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "snowpack": "^3.0.11" + }, + "dependencies": { + "js-yaml": "^4.0.0", + "react": "^17.0.1", + "react-dom": "^17.0.1" + } +} diff --git a/presetup-configurator/snowpack.config.js b/presetup-configurator/snowpack.config.js new file mode 100644 index 00000000..0125dd6f --- /dev/null +++ b/presetup-configurator/snowpack.config.js @@ -0,0 +1,8 @@ +module.exports = { + mount: { + "src": "/", + }, + plugins: [ + '@snowpack/plugin-typescript', + ], +}; diff --git a/presetup-configurator/src/App.css b/presetup-configurator/src/App.css new file mode 100644 index 00000000..2b17506e --- /dev/null +++ b/presetup-configurator/src/App.css @@ -0,0 +1,8 @@ +.App { + display: flex; + justify-content: center; +} + +.App .wrapper { + flex: 0 1 800px; +} diff --git a/presetup-configurator/src/App.tsx b/presetup-configurator/src/App.tsx new file mode 100644 index 00000000..5201bdf8 --- /dev/null +++ b/presetup-configurator/src/App.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Configurator } from "./Configurator"; +import "./App.css"; + +export function App() { + return
+
+ +
+
; +} diff --git a/presetup-configurator/src/Configurator.css b/presetup-configurator/src/Configurator.css new file mode 100644 index 00000000..8bebd777 --- /dev/null +++ b/presetup-configurator/src/Configurator.css @@ -0,0 +1,51 @@ +.Configurator { + +} + +.Configurator .options { + display: grid; + grid-auto-columns: min-content auto; + grid-gap: 1px; + + overflow: hidden; + border: 1px solid #444; + border-radius: 4px; + background-color: #fff; +} + +.Configurator .options > h2 { + grid-column: 1; + + margin: 0; + padding: 8px 24px 8px 8px; + + white-space: nowrap; + text-align: right; + font-size: 16px; + font-weight: 600; + + box-shadow: 0 0 0 1px #444; +} + +.Configurator .options > .control { + grid-column: 2; + + padding: 8px; + box-shadow: 0 0 0 1px #444; +} + +.Configurator label { + display: block; + padding: 0 0 8px; +} + +.Configurator .result { + margin-top: 16px; + width: 100%; + background-color: #eee; + padding: 8px; + border: 1px solid #444; + border-radius: 4px; + box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.2); + cursor: copy; +} diff --git a/presetup-configurator/src/Configurator.tsx b/presetup-configurator/src/Configurator.tsx new file mode 100644 index 00000000..8e2222de --- /dev/null +++ b/presetup-configurator/src/Configurator.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useState } from "react"; +import { LevelEntry, Levels } from "./Levels"; +import { LogChannel, LogChannels } from "./LogChannels"; +import yaml from "js-yaml"; +import "./Configurator.css"; + +export function Configurator() { + const [prefix, setPrefix] = useState('!'); + const [levels, setLevels] = useState([]); + + const [withModCommands, setWithModCommands] = useState(false); + const [muteRoleId, setMuteRoleId] = useState(""); + const [caseChannelId, setCaseChannelId] = useState(""); + const [dmModActionReasons, setDmModActionReasons] = useState(false); + + const [withLogs, setWithLogs] = useState(false); + const [logChannels, setLogChannels] = useState([]); + + const [result, setResult] = useState({}); + useEffect(() => { + const resultObj: any = { + prefix, + levels: levels.reduce((obj, entry) => { + obj[entry[0]] = entry[1]; + return obj; + }, {}), + plugins: { + utility: {}, + }, + }; + + if (withModCommands) { + resultObj.plugins.cases = { + config: { + case_log_channel: caseChannelId, + }, + }; + + resultObj.plugins.mod_actions = {}; + + if (muteRoleId) { + resultObj.plugins.mutes = { + config: { + mute_role: muteRoleId, + } + }; + + if (dmModActionReasons) { + resultObj.plugins.mutes.config.dm_on_mute = true; + } + } + + if (dmModActionReasons) { + resultObj.plugins.mod_actions = { + config: { + dm_on_warn: true, + dm_on_kick: true, + dm_on_ban: true, + }, + }; + } + } + + if (withLogs) { + resultObj.plugins.logs = { + config: { + channels: logChannels.reduce((obj, logChannel) => { + if (logChannel.includeExclude === "include") { + obj[logChannel.id] = { + include: Array.from(logChannel.logTypes.values()), + }; + } else { + obj[logChannel.id] = { + exclude: Array.from(logChannel.logTypes.values()), + }; + } + return obj; + }, {}), + }, + }; + } + + setResult(resultObj); + }, [prefix, levels, withModCommands, muteRoleId, caseChannelId, dmModActionReasons, withLogs, logChannels]); + + const [formattedResult, setFormattedResult] = useState(""); + useEffect(() => { + let _formattedResult = yaml.dump(result); + + // Add line break before each unquoted top-level or second-level property + _formattedResult = _formattedResult.replace(/^ {0,2}[a-z_]+:/gm, "\n$&").trim(); + + // Add additional line break at the end + _formattedResult += "\n"; + + // Explain "exclude: []" + _formattedResult = _formattedResult.replace(/exclude: \[]/, "$& # Exclude nothing = include everything"); + + setFormattedResult(_formattedResult); + }, [result]); + + const resultRows = formattedResult.split("\n").length || 1; + + const [copied, setCopied] = useState(false); + function copyResultText(textarea: HTMLTextAreaElement) { + textarea.select(); + document.execCommand("copy"); + setCopied(true); + } + + const [copyResetTimeout, setCopyResetTimeout] = useState(null); + useEffect(() => { + if (!copied) { + return; + } + + if (copyResetTimeout != null) { + window.clearTimeout(copyResetTimeout); + } + + const timeout = window.setTimeout(() => setCopied(false), 3000); + setCopyResetTimeout(timeout); + }, [copied]); + + return ( +
+ {/* Options */} +
+

Prefix

+
+ +
+ +

Levels

+
+ +
+ +

Mod commands

+
+ + + {withModCommands && ( +
+ + + + + +
+ )} +
+ +

Logs

+
+ + + {withLogs && ( + + )} +
+
+ + {/* Result */} +