diff --git a/.gitignore b/.gitignore index d38433b6..efa6d897 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ desktop.ini .cache npm-ls.txt npm-audit.txt +.vscode/launch.json diff --git a/README.md b/README.md index bfaadfaf..e004b434 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,50 @@ -# Development +# Zeppelin +Zeppelin is a moderation bot for Discord, designed with large servers and reliability in mind. + +**Main features include:** +- Extensive automoderator features (automod) + - Word filters, spam detection, etc. +- Detailed moderator action tracking and notes (cases) +- Customizable server logs +- Tags/custom commands +- Reaction roles +- Tons of utility commands, including a granular member search +- Full configuration via a web dashboard + - Override specific settings and permissions on e.g. a per-user, per-channel, or per-permission-level basis +- Bot-managed slowmodes + - Automatically switches between native slowmodes (for 6h or less) and bot-enforced (for longer slowmodes) +- Starboard +- And more! + +See https://zeppelin.gg/ for more details. + +## Development These instructions are intended for bot development only. 👉 **No support is offered for self-hosting the bot!** 👈 -## Running the bot +### Running the bot 1. `cd backend` 2. `npm ci` 3. Make a copy of `bot.env.example` called `bot.env`, fill in the values 4. Run the desired start script: - * `npm run start-bot-dev` to run the bot with `ts-node` - * `npm run build` followed by `npm run start-bot-prod` to run the bot compiled - * `npm run watch-bot` to run the bot with `ts-node` and restart on changes + * `npm run build` followed by `npm run start-bot-dev` to run the bot in a **development** environment + * `npm run build` followed by `npm run start-bot-prod` to run the bot in a **production** environment + * `npm run watch` to watch files and run the **bot and api both** in a **development** environment + with automatic restart on file changes 5. When testing, make sure you have your test server in the `allowed_guilds` table or the guild's config won't be loaded at all -## Running the API server +### Running the API server 1. `cd backend` 2. `npm ci` 3. Make a copy of `api.env.example` called `api.env`, fill in the values 4. Run the desired start script: - * `npm run start-api-dev` to run the API server with `ts-node` - * `npm run build` followed by `npm run start-api-prod` to run the API server compiled - * `npm run watch-api` to run the API server with `ts-node` and restart on changes + * `npm run build` followed by `npm run start-api-dev` to run the api in a **development** environment + * `npm run build` followed by `npm run start-api-prod` to run the api in a **production** environment + * `npm run watch` to watch files and run the **bot and api both** in a **development** environment + with automatic restart on file changes -## Running the dashboard +### Running the dashboard 1. `cd dashboard` 2. `npm ci` 3. Make a copy of `.env.example` called `.env`, fill in the values @@ -30,15 +52,15 @@ These instructions are intended for bot development only. * `npm run build` compiles the dashboard's static files to `dist/` which can then be served with any web server * `npm run watch` runs webpack's dev server that automatically reloads on changes -## Notes +### Notes * Since we now use shared paths in `tsconfig.json`, the compiled files in `backend/dist/` have longer paths, e.g. `backend/dist/backend/src/index.js` instead of `backend/dist/index.js`. This is because the compiled shared files are placed in `backend/dist/shared`. -* The `backend/register-tsconfig-prod-paths.js` module takes care of registering shared paths from `tsconfig.json` for - `ts-node`, `ava`, and compiled `.js` files +* The `backend/register-tsconfig-paths.js` module takes care of registering shared paths from `tsconfig.json` for + `ava` and compiled `.js` files * To run the tests for the files in the `shared/` directory, you also need to run `npm ci` there -## Config format example +### Config format example Configuration is stored in the database in the `configs` table ```yml diff --git a/backend/nodemon-api.json b/backend/nodemon-api.json deleted file mode 100644 index 759b4e97..00000000 --- a/backend/nodemon-api.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "watch": ["src", "../shared/src"], - "ignore": ["src/migrations/*"], - "ext": "ts", - "exec": "node -r ts-node/register -r tsconfig-paths/register ./src/api/index.ts" -} diff --git a/backend/nodemon-bot.json b/backend/nodemon-bot.json deleted file mode 100644 index 773cda65..00000000 --- a/backend/nodemon-bot.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "watch": ["src", "../shared/src"], - "ignore": ["src/api/*", "src/migrations/*"], - "ext": "ts", - "exec": "node -r ts-node/register -r tsconfig-paths/register ./src/index.ts" -} diff --git a/backend/ormconfig.js b/backend/ormconfig.js index c27a72b4..b7ffe433 100644 --- a/backend/ormconfig.js +++ b/backend/ormconfig.js @@ -16,13 +16,9 @@ try { const moment = require('moment-timezone'); moment.tz.setDefault('UTC'); -const entities = process.env.NODE_ENV === 'production' - ? path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js')) - : path.relative(process.cwd(), path.resolve(__dirname, 'src/data/entities/*.ts')); - -const migrations = process.env.NODE_ENV === 'production' - ? path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js')) - : path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations/*.ts')); +const entities = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js')); +const migrations = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js')); +const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations')); module.exports = { type: "mysql", @@ -55,6 +51,6 @@ module.exports = { // Migrations migrations: [migrations], cli: { - migrationsDir: path.dirname(migrations) + migrationsDir, }, }; diff --git a/backend/package-lock.json b/backend/package-lock.json index a0942190..fa229d2a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -598,6 +598,12 @@ "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", "dev": true }, + "@types/safe-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/safe-regex/-/safe-regex-1.1.2.tgz", + "integrity": "sha512-wuS9LVpgIiTYaGKd+s6Dj0kRXBkttaXjVxzaXmviCACi8RO+INPayND+VNjAcall/l1Jkyhh9lyPfKW/aP/Yug==", + "dev": true + }, "@types/serve-static": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", @@ -614,12 +620,6 @@ "integrity": "sha1-EHPEvIJHVK49EM+riKsCN7qWTk0=", "dev": true }, - "abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true - }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -629,42 +629,6 @@ "negotiator": "0.6.2" } }, - "ansi-align": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz", - "integrity": "sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=", - "dev": true, - "requires": { - "string-width": "^2.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - } - } - }, "ansi-escapes": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.2.1.tgz", @@ -692,27 +656,11 @@ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" }, - "anymatch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", - "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", - "dev": true, - "requires": { - "micromatch": "^3.1.4", - "normalize-path": "^2.1.1" - } - }, "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==" }, - "arg": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.1.tgz", - "integrity": "sha512-SlmP3fEA88MBv0PypnXZ8ZfJhwmDeIE3SP71j37AiXQBXYosPV0x6uISAaHYSlSVhmHOVkomen0tbGk6Anlebw==", - "dev": true - }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -721,24 +669,12 @@ "sprintf-js": "~1.0.2" } }, - "arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", - "dev": true - }, "arr-flatten": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", "dev": true }, - "arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", - "dev": true - }, "array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", @@ -762,47 +698,23 @@ "integrity": "sha512-bdHxtev7FN6+MXI1YFW0Q8mQ8dTJc2S8AMfju+ZR77pbg2yAdVyDlwkaUI7Har0LyOMRFPHrJ9lYdyjZZswdlQ==", "dev": true }, - "array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", - "dev": true - }, "arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", "dev": true }, - "assign-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", - "dev": true - }, "astral-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true }, - "async-each": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", - "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", - "dev": true - }, "async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" }, - "atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "dev": true - }, "ava": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ava/-/ava-2.4.0.tgz", @@ -1382,61 +1294,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, - "base": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", - "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", - "dev": true, - "requires": { - "cache-base": "^1.0.1", - "class-utils": "^0.3.5", - "component-emitter": "^1.2.1", - "define-property": "^1.0.0", - "isobject": "^3.0.1", - "mixin-deep": "^1.2.0", - "pascalcase": "^0.1.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - } - } - }, "base64-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", @@ -1452,12 +1309,6 @@ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz", "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==" }, - "binary-extensions": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", - "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", - "dev": true - }, "bluebird": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.1.tgz", @@ -1487,54 +1338,6 @@ "type-is": "~1.6.17" } }, - "boxen": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz", - "integrity": "sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==", - "dev": true, - "requires": { - "ansi-align": "^2.0.0", - "camelcase": "^4.0.0", - "chalk": "^2.0.1", - "cli-boxes": "^1.0.0", - "string-width": "^2.0.0", - "term-size": "^1.2.0", - "widest-line": "^2.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "camelcase": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", - "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", - "dev": true - }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - } - } - }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1544,35 +1347,6 @@ "concat-map": "0.0.1" } }, - "braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, - "requires": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, "buffer": { "version": "5.4.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", @@ -1593,23 +1367,6 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" }, - "cache-base": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", - "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", - "dev": true, - "requires": { - "collection-visit": "^1.0.0", - "component-emitter": "^1.2.1", - "get-value": "^2.0.6", - "has-value": "^1.0.0", - "isobject": "^3.0.1", - "set-value": "^2.0.0", - "to-object-path": "^0.3.0", - "union-value": "^1.0.0", - "unset-value": "^1.0.0" - } - }, "cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", @@ -1684,12 +1441,6 @@ } } }, - "capture-stack-trace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz", - "integrity": "sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==", - "dev": true - }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -1700,34 +1451,6 @@ "supports-color": "^5.3.0" } }, - "chokidar": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", - "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", - "dev": true, - "requires": { - "anymatch": "^2.0.0", - "async-each": "^1.0.1", - "braces": "^2.3.2", - "fsevents": "^1.2.7", - "glob-parent": "^3.1.0", - "inherits": "^2.0.3", - "is-binary-path": "^1.0.0", - "is-glob": "^4.0.0", - "normalize-path": "^3.0.0", - "path-is-absolute": "^1.0.0", - "readdirp": "^2.2.1", - "upath": "^1.1.1" - }, - "dependencies": { - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - } - } - }, "chunkd": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/chunkd/-/chunkd-1.0.0.tgz", @@ -1746,29 +1469,6 @@ "integrity": "sha512-u6dx20FBXm+apMi+5x7UVm6EH7BL1gc4XrcnQewjcB7HWRcor/V5qWc3RG2HwpgDJ26gIi2DSEu3B7sXynAw/g==", "dev": true }, - "class-utils": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", - "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", - "dev": true, - "requires": { - "arr-union": "^3.1.0", - "define-property": "^0.2.5", - "isobject": "^3.0.0", - "static-extend": "^0.1.1" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - } - } - }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -1781,12 +1481,6 @@ "integrity": "sha1-Y/sRDcLOGoTcIfbZM0h20BCui2g=", "dev": true }, - "cli-boxes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz", - "integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=", - "dev": true - }, "cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -1877,16 +1571,6 @@ "convert-to-spaces": "^1.0.1" } }, - "collection-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", - "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", - "dev": true, - "requires": { - "map-visit": "^1.0.0", - "object-visit": "^1.0.0" - } - }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -1906,12 +1590,6 @@ "integrity": "sha1-zVL28HEuC6q5fW+XModPIvR3UsA=", "dev": true }, - "component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true - }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1947,31 +1625,6 @@ } } }, - "configstore": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz", - "integrity": "sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==", - "dev": true, - "requires": { - "dot-prop": "^4.1.0", - "graceful-fs": "^4.1.2", - "make-dir": "^1.0.0", - "unique-string": "^1.0.0", - "write-file-atomic": "^2.0.0", - "xdg-basedir": "^3.0.0" - }, - "dependencies": { - "make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "requires": { - "pify": "^3.0.0" - } - } - } - }, "content-disposition": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", @@ -2010,12 +1663,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, - "copy-descriptor": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", - "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", - "dev": true - }, "core-js": { "version": "2.6.10", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.10.tgz", @@ -2036,15 +1683,6 @@ "vary": "^1" } }, - "create-error-class": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz", - "integrity": "sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=", - "dev": true, - "requires": { - "capture-stack-trace": "^1.0.0" - } - }, "cross-env": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.1.tgz", @@ -2120,12 +1758,6 @@ } } }, - "decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true - }, "decompress-response": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", @@ -2183,47 +1815,6 @@ "object-keys": "^1.0.12" } }, - "define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "requires": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "dependencies": { - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - } - } - }, "del": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", @@ -2293,12 +1884,6 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, - "diff": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.1.tgz", - "integrity": "sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==", - "dev": true - }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2308,15 +1893,6 @@ "path-type": "^4.0.0" } }, - "dot-prop": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz", - "integrity": "sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==", - "dev": true, - "requires": { - "is-obj": "^1.0.0" - } - }, "dotenv": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-4.0.0.tgz", @@ -2330,6 +1906,12 @@ "ini": "^1.3.5" } }, + "duplexer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", + "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", + "dev": true + }, "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", @@ -2493,39 +2075,19 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, - "expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=", "dev": true, "requires": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" } }, "express": { @@ -2565,92 +2127,6 @@ "vary": "~1.1.2" } }, - "extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", - "dev": true, - "requires": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "dependencies": { - "is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4" - } - } - } - }, - "extglob": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", - "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, - "requires": { - "array-unique": "^0.3.2", - "define-property": "^1.0.0", - "expand-brackets": "^2.1.4", - "extend-shallow": "^2.0.1", - "fragment-cache": "^0.2.1", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - } - } - }, "fast-diff": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", @@ -2747,29 +2223,6 @@ "escape-string-regexp": "^1.0.5" } }, - "fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, "finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -2792,12 +2245,6 @@ "locate-path": "^3.0.0" } }, - "for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", - "dev": true - }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -2808,573 +2255,22 @@ "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.1.1.tgz", "integrity": "sha512-YcWhMdDCFCja0MmaDroTgNu+NWWrrnUEn92nvDgrtVy9Z71YFnhNVIghoHPt8gs82ijoMzFGeWKvArbyICiJgw==" }, - "fragment-cache": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", - "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", - "dev": true, - "requires": { - "map-cache": "^0.2.2" - } - }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, + "from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, - "fsevents": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz", - "integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==", - "dev": true, - "optional": true, - "requires": { - "nan": "^2.12.1", - "node-pre-gyp": "^0.12.0" - }, - "dependencies": { - "abbrev": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "optional": true - }, - "ansi-regex": { - "version": "2.1.1", - "bundled": true, - "dev": true, - "optional": true - }, - "aproba": { - "version": "1.2.0", - "bundled": true, - "dev": true, - "optional": true - }, - "are-we-there-yet": { - "version": "1.1.5", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^2.0.6" - } - }, - "balanced-match": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "brace-expansion": { - "version": "1.1.11", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "chownr": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "optional": true - }, - "code-point-at": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "optional": true - }, - "concat-map": { - "version": "0.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "console-control-strings": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "optional": true - }, - "core-util-is": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "debug": { - "version": "4.1.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "ms": "^2.1.1" - } - }, - "deep-extend": { - "version": "0.6.0", - "bundled": true, - "dev": true, - "optional": true - }, - "delegates": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "detect-libc": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "optional": true - }, - "fs-minipass": { - "version": "1.2.5", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "minipass": "^2.2.1" - } - }, - "fs.realpath": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "gauge": { - "version": "2.7.4", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "aproba": "^1.0.3", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.0", - "object-assign": "^4.1.0", - "signal-exit": "^3.0.0", - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wide-align": "^1.1.0" - } - }, - "glob": { - "version": "7.1.3", - "bundled": true, - "dev": true, - "optional": true, - "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-unicode": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "iconv-lite": { - "version": "0.4.24", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "ignore-walk": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "minimatch": "^3.0.4" - } - }, - "inflight": { - "version": "1.0.6", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.3", - "bundled": true, - "dev": true, - "optional": true - }, - "ini": { - "version": "1.3.5", - "bundled": true, - "dev": true, - "optional": true - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "isarray": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "minimatch": { - "version": "3.0.4", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "0.0.8", - "bundled": true, - "dev": true, - "optional": true - }, - "minipass": { - "version": "2.3.5", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safe-buffer": "^5.1.2", - "yallist": "^3.0.0" - } - }, - "minizlib": { - "version": "1.2.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "minipass": "^2.2.1" - } - }, - "mkdirp": { - "version": "0.5.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "minimist": "0.0.8" - } - }, - "ms": { - "version": "2.1.1", - "bundled": true, - "dev": true, - "optional": true - }, - "needle": { - "version": "2.3.0", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "debug": "^4.1.0", - "iconv-lite": "^0.4.4", - "sax": "^1.2.4" - } - }, - "node-pre-gyp": { - "version": "0.12.0", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "detect-libc": "^1.0.2", - "mkdirp": "^0.5.1", - "needle": "^2.2.1", - "nopt": "^4.0.1", - "npm-packlist": "^1.1.6", - "npmlog": "^4.0.2", - "rc": "^1.2.7", - "rimraf": "^2.6.1", - "semver": "^5.3.0", - "tar": "^4" - } - }, - "nopt": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "abbrev": "1", - "osenv": "^0.1.4" - } - }, - "npm-bundled": { - "version": "1.0.6", - "bundled": true, - "dev": true, - "optional": true - }, - "npm-packlist": { - "version": "1.4.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "ignore-walk": "^3.0.1", - "npm-bundled": "^1.0.1" - } - }, - "npmlog": { - "version": "4.1.2", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "are-we-there-yet": "~1.1.2", - "console-control-strings": "~1.1.0", - "gauge": "~2.7.3", - "set-blocking": "~2.0.0" - } - }, - "number-is-nan": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "object-assign": { - "version": "4.1.1", - "bundled": true, - "dev": true, - "optional": true - }, - "once": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "wrappy": "1" - } - }, - "os-homedir": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "os-tmpdir": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "osenv": { - "version": "0.1.5", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "os-homedir": "^1.0.0", - "os-tmpdir": "^1.0.0" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "process-nextick-args": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "rc": { - "version": "1.2.8", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "dependencies": { - "minimist": { - "version": "1.2.0", - "bundled": true, - "dev": true, - "optional": true - } - } - }, - "readable-stream": { - "version": "2.3.6", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "rimraf": { - "version": "2.6.3", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.1.2", - "bundled": true, - "dev": true, - "optional": true - }, - "safer-buffer": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "optional": true - }, - "sax": { - "version": "1.2.4", - "bundled": true, - "dev": true, - "optional": true - }, - "semver": { - "version": "5.7.0", - "bundled": true, - "dev": true, - "optional": true - }, - "set-blocking": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "signal-exit": { - "version": "3.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "string-width": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "string_decoder": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safe-buffer": "~5.1.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "strip-json-comments": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "tar": { - "version": "4.4.8", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "chownr": "^1.1.1", - "fs-minipass": "^1.2.5", - "minipass": "^2.3.4", - "minizlib": "^1.1.1", - "mkdirp": "^0.5.0", - "safe-buffer": "^5.1.2", - "yallist": "^3.0.2" - } - }, - "util-deprecate": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "wide-align": { - "version": "1.1.3", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "string-width": "^1.0.2 || 2" - } - }, - "wrappy": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "yallist": { - "version": "3.0.3", - "bundled": true, - "dev": true, - "optional": true - } - } - }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -3411,12 +2307,6 @@ "pump": "^3.0.0" } }, - "get-value": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", - "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", - "dev": true - }, "glob": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.5.tgz", @@ -3430,27 +2320,6 @@ "path-is-absolute": "^1.0.0" } }, - "glob-parent": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", - "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", - "dev": true, - "requires": { - "is-glob": "^3.1.0", - "path-dirname": "^1.0.0" - }, - "dependencies": { - "is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", - "dev": true, - "requires": { - "is-extglob": "^2.1.0" - } - } - } - }, "global-dirs": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", @@ -3482,33 +2351,6 @@ "slash": "^3.0.0" } }, - "got": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz", - "integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=", - "dev": true, - "requires": { - "create-error-class": "^3.0.0", - "duplexer3": "^0.1.4", - "get-stream": "^3.0.0", - "is-redirect": "^1.0.0", - "is-retry-allowed": "^1.0.0", - "is-stream": "^1.0.0", - "lowercase-keys": "^1.0.0", - "safe-buffer": "^5.0.1", - "timed-out": "^4.0.0", - "unzip-response": "^2.0.1", - "url-parse-lax": "^1.0.0" - }, - "dependencies": { - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", - "dev": true - } - } - }, "graceful-fs": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", @@ -3548,38 +2390,6 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=" }, - "has-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", - "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", - "dev": true, - "requires": { - "get-value": "^2.0.6", - "has-values": "^1.0.0", - "isobject": "^3.0.0" - } - }, - "has-values": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", - "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", - "dev": true, - "requires": { - "is-number": "^3.0.0", - "kind-of": "^4.0.0" - }, - "dependencies": { - "kind-of": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", - "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "has-yarn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", @@ -3732,26 +2542,6 @@ "integrity": "sha512-Y75zBYLkh0lJ9qxeHlMjQ7bSbyiSqNW/UOPWDmzC7cXskL1hekSITh1Oc6JV0XCWWZ9DE8VYSB71xocLk3gmGw==", "dev": true }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "is-arguments": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", @@ -3764,21 +2554,6 @@ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, - "is-binary-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", - "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", - "dev": true, - "requires": { - "binary-extensions": "^1.0.0" - } - }, - "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true - }, "is-callable": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", @@ -3793,62 +2568,17 @@ "ci-info": "^2.0.0" } }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "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-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" - }, - "dependencies": { - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } - } - }, "is-error": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", "dev": true }, - "is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", - "dev": true - }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3879,38 +2609,6 @@ "is-path-inside": "^1.0.0" } }, - "is-npm": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-1.0.0.tgz", - "integrity": "sha1-8vtjpl5JBbQGyGBydloaTceTufQ=", - "dev": true - }, - "is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", - "dev": true - }, "is-observable": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-2.0.0.tgz", @@ -3958,27 +2656,12 @@ "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", "dev": true }, - "is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "requires": { - "isobject": "^3.0.1" - } - }, "is-promise": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", "dev": true }, - "is-redirect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", - "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=", - "dev": true - }, "is-regex": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", @@ -3987,12 +2670,6 @@ "has": "^1.0.1" } }, - "is-retry-allowed": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", - "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", - "dev": true - }, "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -4025,12 +2702,6 @@ "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", "dev": true }, - "is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true - }, "is-yarn-global": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", @@ -4047,12 +2718,6 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - }, "js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", @@ -4110,19 +2775,13 @@ "json-buffer": "3.0.0" } }, - "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", - "dev": true - }, "knub": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/knub/-/knub-26.1.0.tgz", - "integrity": "sha512-QeFb2R3l2jBEF/jfJn2ahJT6ueNF6KruNMhusntP7HKPzTM0YL0k22VTq6wMGMn3obEOkdQ/r3ENg26OWz5CkA==", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/knub/-/knub-28.0.0.tgz", + "integrity": "sha512-dhLjlQP32AbkXoD0tUFrqa8/Emh/U5lvYXNCz+bIWiAlzYr1cQKRuh9qNjAL944sCWmtfhKXVMXRFS99P6M7qw==", "requires": { "escape-string-regexp": "^2.0.0", - "knub-command-manager": "^6.1.0", + "knub-command-manager": "^7.0.0", "lodash.clonedeep": "^4.5.0", "reflect-metadata": "^0.1.13", "ts-essentials": "^2.0.12" @@ -4136,9 +2795,9 @@ } }, "knub-command-manager": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/knub-command-manager/-/knub-command-manager-6.1.0.tgz", - "integrity": "sha512-Bn//fk3ZKUNoJ+p0fNdUfbcyzTUdWHGaP12irSy8U1lfxy3pBrOZZsc0tpIkBFLpwLWw/VxHInX1x2b6MFhn0Q==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/knub-command-manager/-/knub-command-manager-7.0.0.tgz", + "integrity": "sha512-6Vhw3jtIyr5AqVqLcdK6/TbVcZ45hcTw+50w+5Z3YYEqBV1eiPO3WXZpEgaiTUmn0Q60OPOSzt73Bu6DyTBR6A==", "requires": { "escape-string-regexp": "^2.0.0" }, @@ -4158,15 +2817,6 @@ "dotgitconfig": "^1.0.1" } }, - "latest-version": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz", - "integrity": "sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=", - "dev": true, - "requires": { - "package-json": "^4.0.0" - } - }, "load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -4294,32 +2944,17 @@ } } }, - "make-error": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.5.tgz", - "integrity": "sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==", - "dev": true - }, - "map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", - "dev": true - }, "map-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", "integrity": "sha1-plzSkIepJZi4eRJXpSPgISIqwfk=", "dev": true }, - "map-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", - "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", - "dev": true, - "requires": { - "object-visit": "^1.0.0" - } + "map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=", + "dev": true }, "matcher": { "version": "2.0.0", @@ -4418,27 +3053,6 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" }, - "micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "requires": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - } - }, "mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -4492,27 +3106,6 @@ "is-plain-obj": "^1.1.0" } }, - "mixin-deep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", - "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", - "dev": true, - "requires": { - "for-in": "^1.0.2", - "is-extendable": "^1.0.1" - }, - "dependencies": { - "is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4" - } - } - } - }, "mkdirp": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", @@ -4567,32 +3160,6 @@ "thenify-all": "^1.0.0" } }, - "nan": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", - "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", - "dev": true, - "optional": true - }, - "nanomatch": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", - "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", - "dev": true, - "requires": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "fragment-cache": "^0.2.1", - "is-windows": "^1.0.2", - "kind-of": "^6.0.2", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - } - }, "negotiator": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", @@ -4603,49 +3170,11 @@ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" }, - "nodemon": { - "version": "1.19.4", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.19.4.tgz", - "integrity": "sha512-VGPaqQBNk193lrJFotBU8nvWZPqEZY2eIzymy2jjY0fJ9qIsxA0sxQ8ATPl0gZC645gijYEc1jtZvpS8QWzJGQ==", - "dev": true, - "requires": { - "chokidar": "^2.1.8", - "debug": "^3.2.6", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.0.4", - "pstree.remy": "^1.1.7", - "semver": "^5.7.1", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.2", - "update-notifier": "^2.5.0" - }, - "dependencies": { - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } - } - }, - "nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", - "dev": true, - "requires": { - "abbrev": "1" - } + "node-cleanup": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", + "integrity": "sha1-esGavSl+Caf3KnFUXZUbUX5N3iw=", + "dev": true }, "normalize-package-data": { "version": "2.5.0", @@ -4659,15 +3188,6 @@ "validate-npm-package-license": "^3.0.1" } }, - "normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", - "dev": true, - "requires": { - "remove-trailing-separator": "^1.0.1" - } - }, "normalize-url": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", @@ -4693,37 +3213,6 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, - "object-copy": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", - "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", - "dev": true, - "requires": { - "copy-descriptor": "^0.1.0", - "define-property": "^0.2.5", - "kind-of": "^3.0.3" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "object-inspect": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", @@ -4740,15 +3229,6 @@ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" }, - "object-visit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", - "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", - "dev": true, - "requires": { - "isobject": "^3.0.0" - } - }, "object.assign": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", @@ -4770,15 +3250,6 @@ "es-abstract": "^1.5.1" } }, - "object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", - "dev": true, - "requires": { - "isobject": "^3.0.1" - } - }, "observable-to-promise": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/observable-to-promise/-/observable-to-promise-1.0.0.tgz", @@ -4926,18 +3397,6 @@ "release-zalgo": "^1.0.0" } }, - "package-json": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-4.0.1.tgz", - "integrity": "sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=", - "dev": true, - "requires": { - "got": "^6.7.1", - "registry-auth-token": "^3.0.1", - "registry-url": "^3.0.3", - "semver": "^5.1.0" - } - }, "parent-require": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/parent-require/-/parent-require-1.0.0.tgz", @@ -4969,12 +3428,6 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, - "pascalcase": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", - "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", - "dev": true - }, "passport": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.0.tgz", @@ -5009,12 +3462,6 @@ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" }, - "path-dirname": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", - "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", - "dev": true - }, "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -5058,6 +3505,15 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" }, + "pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=", + "dev": true, + "requires": { + "through": "~2.3" + } + }, "picomatch": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.1.0.tgz", @@ -5176,18 +3632,6 @@ "irregular-plurals": "^2.0.0" } }, - "posix-character-classes": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", - "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", - "dev": true - }, - "prepend-http": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", - "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=", - "dev": true - }, "pretty-ms": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.0.0.tgz", @@ -5211,18 +3655,21 @@ "ipaddr.js": "1.9.0" } }, + "ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", + "dev": true, + "requires": { + "event-stream": "=3.3.4" + } + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", "dev": true }, - "pstree.remy": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.7.tgz", - "integrity": "sha512-xsMgrUwRpuGskEzBFkH8NmTimbZ5PcPup0LA8JJkHIm2IMUbQcpo3yeLNWVrufEYjh8YwtSVh0xz6UeWc5Oh5A==", - "dev": true - }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -5363,17 +3810,6 @@ "util-deprecate": "~1.0.1" } }, - "readdirp": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", - "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.11", - "micromatch": "^3.1.10", - "readable-stream": "^2.0.2" - } - }, "redent": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-2.0.0.tgz", @@ -5412,27 +3848,6 @@ "regenerate": "^1.4.0" } }, - "regex-not": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", - "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", - "dev": true, - "requires": { - "extend-shallow": "^3.0.2", - "safe-regex": "^1.1.0" - }, - "dependencies": { - "safe-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", - "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", - "dev": true, - "requires": { - "ret": "~0.1.10" - } - } - } - }, "regexp-tree": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.14.tgz", @@ -5461,25 +3876,6 @@ "unicode-match-property-value-ecmascript": "^1.1.0" } }, - "registry-auth-token": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.4.0.tgz", - "integrity": "sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==", - "dev": true, - "requires": { - "rc": "^1.1.6", - "safe-buffer": "^5.0.1" - } - }, - "registry-url": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", - "integrity": "sha1-PU74cPc93h138M+aOBQyRE4XSUI=", - "dev": true, - "requires": { - "rc": "^1.0.1" - } - }, "regjsgen": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.1.tgz", @@ -5512,24 +3908,6 @@ "es6-error": "^4.0.1" } }, - "remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", - "dev": true - }, - "repeat-element": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", - "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", - "dev": true - }, - "repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", - "dev": true - }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5570,12 +3948,6 @@ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true }, - "resolve-url": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", - "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", - "dev": true - }, "responselike": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", @@ -5595,12 +3967,6 @@ "signal-exit": "^3.0.2" } }, - "ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true - }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -5713,29 +4079,6 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, - "set-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", - "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.3", - "split-string": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, "setprototypeof": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", @@ -5777,132 +4120,12 @@ "is-fullwidth-code-point": "^2.0.0" } }, - "snapdragon": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", - "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", - "dev": true, - "requires": { - "base": "^0.11.1", - "debug": "^2.2.0", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "map-cache": "^0.2.2", - "source-map": "^0.5.6", - "source-map-resolve": "^0.5.0", - "use": "^3.1.0" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "snapdragon-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", - "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", - "dev": true, - "requires": { - "define-property": "^1.0.0", - "isobject": "^3.0.0", - "snapdragon-util": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - } - } - }, - "snapdragon-util": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", - "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", - "dev": true, - "requires": { - "kind-of": "^3.2.0" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", "dev": true }, - "source-map-resolve": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz", - "integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==", - "dev": true, - "requires": { - "atob": "^2.1.1", - "decode-uri-component": "^0.2.0", - "resolve-url": "^0.2.1", - "source-map-url": "^0.4.0", - "urix": "^0.1.0" - } - }, "source-map-support": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz", @@ -5921,12 +4144,6 @@ } } }, - "source-map-url": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", - "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", - "dev": true - }, "spdx-correct": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", @@ -5959,13 +4176,13 @@ "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", "dev": true }, - "split-string": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", - "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=", "dev": true, "requires": { - "extend-shallow": "^3.0.0" + "through": "2" } }, "sprintf-js": { @@ -5984,32 +4201,26 @@ "integrity": "sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA==", "dev": true }, - "static-extend": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", - "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", - "dev": true, - "requires": { - "define-property": "^0.2.5", - "object-copy": "^0.1.0" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - } - } - }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, + "stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=", + "dev": true, + "requires": { + "duplexer": "~0.1.1" + } + }, + "string-argv": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.1.2.tgz", + "integrity": "sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA==", + "dev": true + }, "string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", @@ -6202,18 +4413,18 @@ "thenify": ">= 3.1.0 < 4" } }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, "time-zone": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz", "integrity": "sha1-mcW/VZWJZq9tBtg73zgA3IL67F0=", "dev": true }, - "timed-out": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", - "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=", - "dev": true - }, "tlds": { "version": "1.203.1", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.203.1.tgz", @@ -6233,77 +4444,52 @@ "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", "dev": true }, - "to-object-path": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", - "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", "dev": true }, - "to-regex": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", - "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", - "dev": true, - "requires": { - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "regex-not": "^1.0.2", - "safe-regex": "^1.1.0" - }, - "dependencies": { - "safe-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", - "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", - "dev": true, - "requires": { - "ret": "~0.1.10" - } - } - } - }, - "to-regex-range": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", - "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", - "dev": true, - "requires": { - "is-number": "^3.0.0", - "repeat-string": "^1.6.1" - } - }, "toidentifier": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, - "touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", - "dev": true, + "transliteration": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/transliteration/-/transliteration-2.1.7.tgz", + "integrity": "sha512-o3678GPmKKGqOBB+trAKzhBUjHddU18He2V8AKB1XuegaGJekO0xmfkkvbc9LCBat62nb7IH8z5/OJY+mNugkg==", "requires": { - "nopt": "~1.0.10" + "yargs": "^14.0.0" + }, + "dependencies": { + "yargs": { + "version": "14.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.2.tgz", + "integrity": "sha512-/4ld+4VV5RnrynMhPZJ/ZpOCGSCeghMykZ3BhdFBDa9Wy/RH6uEGNWDJog+aUlq+9OM1CFTgtYRW5Is1Po9NOA==", + "requires": { + "cliui": "^5.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "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": "^15.0.0" + } + }, + "yargs-parser": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.0.tgz", + "integrity": "sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } } }, "trim-newlines": { @@ -6329,17 +4515,45 @@ "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-2.0.12.tgz", "integrity": "sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w==" }, - "ts-node": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.4.1.tgz", - "integrity": "sha512-5LpRN+mTiCs7lI5EtbXmF/HfMeCjzt7DH9CZwtkr6SywStrNQC723wG+aOWFiLNn7zT3kD/RnFqi3ZUfr4l5Qw==", + "tsc-watch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-4.0.0.tgz", + "integrity": "sha512-I+1cE7WN9YhDknNRAO5NRI7jzeiIZCxUZ0dFEM/Gf+3KTlHasusDEftwezJ+PSFkECSn3RQmf28RdovjTptkRA==", "dev": true, "requires": { - "arg": "^4.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "source-map-support": "^0.5.6", - "yn": "^3.0.0" + "cross-spawn": "^5.1.0", + "node-cleanup": "^2.1.2", + "ps-tree": "^1.2.0", + "string-argv": "^0.1.1", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } } }, "tsconfig-paths": { @@ -6450,15 +4664,6 @@ "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", "integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I=" }, - "undefsafe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.2.tgz", - "integrity": "sha1-Il9rngM3Zj4Njnz9aG/Cg2zKznY=", - "dev": true, - "requires": { - "debug": "^2.2.0" - } - }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -6487,18 +4692,6 @@ "integrity": "sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==", "dev": true }, - "union-value": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", - "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", - "dev": true, - "requires": { - "arr-union": "^3.1.0", - "get-value": "^2.0.6", - "is-extendable": "^0.1.1", - "set-value": "^2.0.1" - } - }, "unique-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", @@ -6524,114 +4717,6 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, - "unset-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", - "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", - "dev": true, - "requires": { - "has-value": "^0.3.1", - "isobject": "^3.0.0" - }, - "dependencies": { - "has-value": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", - "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", - "dev": true, - "requires": { - "get-value": "^2.0.3", - "has-values": "^0.1.4", - "isobject": "^2.0.0" - }, - "dependencies": { - "isobject": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", - "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", - "dev": true, - "requires": { - "isarray": "1.0.0" - } - } - } - }, - "has-values": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", - "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", - "dev": true - } - } - }, - "unzip-response": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz", - "integrity": "sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=", - "dev": true - }, - "upath": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", - "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", - "dev": true - }, - "update-notifier": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-2.5.0.tgz", - "integrity": "sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==", - "dev": true, - "requires": { - "boxen": "^1.2.1", - "chalk": "^2.0.1", - "configstore": "^3.0.0", - "import-lazy": "^2.1.0", - "is-ci": "^1.0.10", - "is-installed-globally": "^0.1.0", - "is-npm": "^1.0.0", - "latest-version": "^3.0.0", - "semver-diff": "^2.0.0", - "xdg-basedir": "^3.0.0" - }, - "dependencies": { - "ci-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", - "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==", - "dev": true - }, - "is-ci": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz", - "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==", - "dev": true, - "requires": { - "ci-info": "^1.5.0" - } - } - } - }, - "urix": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", - "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", - "dev": true - }, - "url-parse-lax": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", - "integrity": "sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=", - "dev": true, - "requires": { - "prepend-http": "^1.0.1" - } - }, - "use": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", - "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", - "dev": true - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6750,17 +4835,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, - "write-file-atomic": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.1.tgz", - "integrity": "sha512-TGHFeZEZMnv+gBFRfjAcxL5bPHrsGKtnb4qsFAws7/vlh+QfwAaySIw4AXP9ZskTTh5GWu3FLuJhsWVdiJPGvg==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.11", - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.2" - } - }, "ws": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.0.tgz", @@ -6879,12 +4953,6 @@ "camelcase": "^5.0.0", "decamelize": "^1.2.0" } - }, - "yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true } } } diff --git a/backend/package.json b/backend/package.json index f53089ec..d110bee6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,18 +4,20 @@ "description": "", "private": true, "scripts": { - "start-bot-dev": "node -r ts-node/register -r tsconfig-paths/register src/index.ts", - "start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-prod-paths.js dist/backend/src/index.js", - "watch-bot": "nodemon --config nodemon-bot.json", + "watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"", "build": "rimraf dist && tsc", - "start-api-dev": "node -r ts-node/register -r tsconfig-paths/register src/api/index.ts", - "start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-prod-paths.js dist/backend/src/api/index.js", - "watch-api": "nodemon --config nodemon-api.json", - "format": "prettier --write \"./src/**/*.ts\"", - "typeorm": "node -r ts-node/register -r tsconfig-paths/register ./node_modules/typeorm/cli.js", - "migrate": "npm run typeorm -- migration:run", - "migrate-rollback": "npm run typeorm -- migration:revert", - "test": "ava" + "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-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js", + "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-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js", + "typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js", + "migrate-prod": "npm run typeorm -- migration:run", + "migrate-dev": "npm run build && npm run typeorm -- migration:run", + "migrate-rollback-prod": "npm run typeorm -- migration:revert", + "migrate-rollback-dev": "npm run build && npm run typeorm -- migration:revert", + "test": "npm run build && npm run run-tests", + "run-tests": "ava", + "test-watch": "tsc-watch --onSuccess \"npx ava\"" }, "dependencies": { "cors": "^2.8.5", @@ -30,8 +32,8 @@ "humanize-duration": "^3.15.0", "io-ts": "^2.0.0", "js-yaml": "^3.13.1", - "knub": "^26.0.2", - "knub-command-manager": "^6.1.0", + "knub": "^28.0.0", + "knub-command-manager": "^7.0.0", "last-commit-log": "^2.1.0", "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", @@ -49,6 +51,7 @@ "seedrandom": "^3.0.1", "tlds": "^1.203.1", "tmp": "0.0.33", + "transliteration": "^2.1.7", "tsconfig-paths": "^3.9.0", "typeorm": "^0.2.14", "uuid": "^3.3.2" @@ -64,24 +67,21 @@ "@types/passport": "^1.0.0", "@types/passport-oauth2": "^1.4.8", "@types/passport-strategy": "^0.2.35", + "@types/safe-regex": "^1.1.2", "@types/tmp": "0.0.33", "ava": "^2.4.0", - "nodemon": "^1.19.4", "rimraf": "^2.6.2", - "ts-node": "^8.4.1", + "source-map-support": "^0.5.16", + "tsc-watch": "^4.0.0", "typescript": "^3.7.2" }, "ava": { "compileEnhancements": false, "files": [ - "src/**/*.test.ts" - ], - "extensions": [ - "ts" + "dist/backend/src/**/*.test.js" ], "require": [ - "ts-node/register", - "tsconfig-paths/register" + "./register-tsconfig-paths.js" ] } } diff --git a/backend/register-tsconfig-prod-paths.js b/backend/register-tsconfig-paths.js similarity index 100% rename from backend/register-tsconfig-prod-paths.js rename to backend/register-tsconfig-paths.js diff --git a/backend/src/api/guilds.ts b/backend/src/api/guilds.ts index de5d9597..7ba01ca5 100644 --- a/backend/src/api/guilds.ts +++ b/backend/src/api/guilds.ts @@ -53,6 +53,7 @@ export function initGuildsAPI(app: express.Express) { return res.status(400).json({ errors: [e.message] }); } + // tslint:disable-next-line:no-console console.error("Error when loading YAML: " + e.message); return serverError(res, "Server error"); } diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index 3bd715be..b5eded54 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -55,8 +55,10 @@ "MEMBER_MUTE_REJOIN": "⚠ Reapplied active mute for {userMention(member)} on rejoin", "SCHEDULED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC)", + "SCHEDULED_REPEATED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC), repeated {repeatDetails}", + "REPEATED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", "POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "BOT_ALERT": "⚠ {tmplEval(body)}", - "AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}. Actions taken: **{actionsTaken}**\n{matchSummary}" + "AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}\n{matchSummary}\nActions taken: **{actionsTaken}**" } diff --git a/backend/src/data/GuildLogs.ts b/backend/src/data/GuildLogs.ts index 042e5e5f..58488906 100644 --- a/backend/src/data/GuildLogs.ts +++ b/backend/src/data/GuildLogs.ts @@ -50,6 +50,9 @@ export class GuildLogs extends EventEmitter { } clearIgnoredLog(type: LogType, ignoreId: any) { - this.ignoredLogs.splice(this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), 1); + this.ignoredLogs.splice( + this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), + 1, + ); } } diff --git a/backend/src/data/GuildReactionRoles.ts b/backend/src/data/GuildReactionRoles.ts index 0f7196c2..c197bc1d 100644 --- a/backend/src/data/GuildReactionRoles.ts +++ b/backend/src/data/GuildReactionRoles.ts @@ -50,13 +50,14 @@ export class GuildReactionRoles extends BaseGuildRepository { await this.reactionRoles.delete(criteria); } - async add(channelId: string, messageId: string, emoji: string, roleId: string) { + async add(channelId: string, messageId: string, emoji: string, roleId: string, exclusive?: boolean) { await this.reactionRoles.insert({ guild_id: this.guildId, channel_id: channelId, message_id: messageId, emoji, role_id: roleId, + is_exclusive: Boolean(exclusive), }); } } diff --git a/backend/src/data/GuildReminders.ts b/backend/src/data/GuildReminders.ts index 13f2f5b2..b370b465 100644 --- a/backend/src/data/GuildReminders.ts +++ b/backend/src/data/GuildReminders.ts @@ -34,13 +34,14 @@ export class GuildReminders extends BaseGuildRepository { }); } - async add(userId: string, channelId: string, remindAt: string, body: string) { + async add(userId: string, channelId: string, remindAt: string, body: string, created_at: string) { await this.reminders.insert({ guild_id: this.guildId, user_id: userId, channel_id: channelId, remind_at: remindAt, body, + created_at, }); } } diff --git a/backend/src/data/GuildScheduledPosts.ts b/backend/src/data/GuildScheduledPosts.ts index 0e37426e..af621434 100644 --- a/backend/src/data/GuildScheduledPosts.ts +++ b/backend/src/data/GuildScheduledPosts.ts @@ -38,4 +38,8 @@ export class GuildScheduledPosts extends BaseGuildRepository { guild_id: this.guildId, }); } + + async update(id: number, data: Partial) { + await this.scheduledPosts.update(id, data); + } } diff --git a/backend/src/data/GuildStarboardMessages.ts b/backend/src/data/GuildStarboardMessages.ts new file mode 100644 index 00000000..c01bb8dc --- /dev/null +++ b/backend/src/data/GuildStarboardMessages.ts @@ -0,0 +1,54 @@ +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { getRepository, Repository } from "typeorm"; +import { StarboardMessage } from "./entities/StarboardMessage"; + +export class GuildStarboardMessages extends BaseGuildRepository { + private allStarboardMessages: Repository; + + constructor(guildId) { + super(guildId); + this.allStarboardMessages = getRepository(StarboardMessage); + } + + async getStarboardMessagesForMessageId(messageId: string) { + return this.allStarboardMessages + .createQueryBuilder() + .where("guild_id = :gid", { gid: this.guildId }) + .andWhere("message_id = :msgid", { msgid: messageId }) + .getMany(); + } + + async getStarboardMessagesForStarboardMessageId(starboardMessageId: string) { + return this.allStarboardMessages + .createQueryBuilder() + .where("guild_id = :gid", { gid: this.guildId }) + .andWhere("starboard_message_id = :messageId", { messageId: starboardMessageId }) + .getMany(); + } + + async getMatchingStarboardMessages(starboardChannelId: string, sourceMessageId: string) { + return this.allStarboardMessages + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .andWhere("message_id = :msgId", { msgId: sourceMessageId }) + .andWhere("starboard_channel_id = :channelId", { channelId: starboardChannelId }) + .getMany(); + } + + async createStarboardMessage(starboardId: string, messageId: string, starboardMessageId: string) { + await this.allStarboardMessages.insert({ + message_id: messageId, + starboard_message_id: starboardMessageId, + starboard_channel_id: starboardId, + guild_id: this.guildId, + }); + } + + async deleteStarboardMessage(starboardMessageId: string, starboardChannelId: string) { + await this.allStarboardMessages.delete({ + guild_id: this.guildId, + starboard_message_id: starboardMessageId, + starboard_channel_id: starboardChannelId, + }); + } +} diff --git a/backend/src/data/GuildStarboardReactions.ts b/backend/src/data/GuildStarboardReactions.ts new file mode 100644 index 00000000..6f81f735 --- /dev/null +++ b/backend/src/data/GuildStarboardReactions.ts @@ -0,0 +1,43 @@ +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { Repository, getRepository } from "typeorm"; +import { StarboardReaction } from "./entities/StarboardReaction"; + +export class GuildStarboardReactions extends BaseGuildRepository { + private allStarboardReactions: Repository; + + constructor(guildId) { + super(guildId); + this.allStarboardReactions = getRepository(StarboardReaction); + } + + async getAllReactionsForMessageId(messageId: string) { + return this.allStarboardReactions + .createQueryBuilder() + .where("guild_id = :gid", { gid: this.guildId }) + .andWhere("message_id = :msgid", { msgid: messageId }) + .getMany(); + } + + async createStarboardReaction(messageId: string, reactorId: string) { + await this.allStarboardReactions.insert({ + message_id: messageId, + reactor_id: reactorId, + guild_id: this.guildId, + }); + } + + async deleteAllStarboardReactionsForMessageId(messageId: string) { + await this.allStarboardReactions.delete({ + guild_id: this.guildId, + message_id: messageId, + }); + } + + async deleteStarboardReaction(messageId: string, reactorId: string) { + await this.allStarboardReactions.delete({ + guild_id: this.guildId, + reactor_id: reactorId, + message_id: messageId, + }); + } +} diff --git a/backend/src/data/GuildStarboards.ts b/backend/src/data/GuildStarboards.ts deleted file mode 100644 index b5f86e08..00000000 --- a/backend/src/data/GuildStarboards.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { BaseGuildRepository } from "./BaseGuildRepository"; -import { getRepository, Repository } from "typeorm"; -import { Starboard } from "./entities/Starboard"; -import { StarboardMessage } from "./entities/StarboardMessage"; - -export class GuildStarboards extends BaseGuildRepository { - private starboards: Repository; - private starboardMessages: Repository; - - constructor(guildId) { - super(guildId); - this.starboards = getRepository(Starboard); - this.starboardMessages = getRepository(StarboardMessage); - } - - getStarboardByChannelId(channelId): Promise { - return this.starboards.findOne({ - where: { - guild_id: this.guildId, - channel_id: channelId, - }, - }); - } - - getStarboardsByEmoji(emoji): Promise { - return this.starboards.find({ - where: { - guild_id: this.guildId, - emoji, - }, - }); - } - - getStarboardMessageByStarboardIdAndMessageId(starboardId, messageId): Promise { - return this.starboardMessages.findOne({ - relations: this.getRelations(), - where: { - starboard_id: starboardId, - message_id: messageId, - }, - }); - } - - getStarboardMessagesByMessageId(id): Promise { - return this.starboardMessages.find({ - relations: this.getRelations(), - where: { - message_id: id, - }, - }); - } - - async createStarboardMessage(starboardId, messageId, starboardMessageId): Promise { - await this.starboardMessages.insert({ - starboard_id: starboardId, - message_id: messageId, - starboard_message_id: starboardMessageId, - }); - } - - async deleteStarboardMessage(starboardId, messageId): Promise { - await this.starboardMessages.delete({ - starboard_id: starboardId, - message_id: messageId, - }); - } - - async create(channelId: string, channelWhitelist: string[], emoji: string, reactionsRequired: number): Promise { - await this.starboards.insert({ - guild_id: this.guildId, - channel_id: channelId, - channel_whitelist: channelWhitelist ? channelWhitelist.join(",") : null, - emoji, - reactions_required: reactionsRequired, - }); - } - - async delete(channelId: string): Promise { - await this.starboards.delete({ - guild_id: this.guildId, - channel_id: channelId, - }); - } -} diff --git a/backend/src/data/GuildStats.ts b/backend/src/data/GuildStats.ts new file mode 100644 index 00000000..46c7497b --- /dev/null +++ b/backend/src/data/GuildStats.ts @@ -0,0 +1,30 @@ +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { connection } from "./db"; +import { getRepository, Repository } from "typeorm"; +import { StatValue } from "./entities/StatValue"; + +export class GuildStats extends BaseGuildRepository { + private stats: Repository; + + constructor(guildId) { + super(guildId); + this.stats = getRepository(StatValue); + } + + async saveValue(source: string, key: string, value: number): Promise { + await this.stats.insert({ + guild_id: this.guildId, + source, + key, + value, + }); + } + + async deleteOldValues(source: string, cutoff: string): Promise { + await this.stats + .createQueryBuilder() + .where("source = :source", { source }) + .andWhere("created_at < :cutoff", { cutoff }) + .delete(); + } +} diff --git a/backend/src/data/LogType.ts b/backend/src/data/LogType.ts index 1be6580b..31798b78 100644 --- a/backend/src/data/LogType.ts +++ b/backend/src/data/LogType.ts @@ -59,4 +59,7 @@ export enum LogType { BOT_ALERT, AUTOMOD_ACTION, + + SCHEDULED_REPEATED_MESSAGE, + REPEATED_MESSAGE, } diff --git a/backend/src/data/entities/ApiLogin.ts b/backend/src/data/entities/ApiLogin.ts index 76204224..5dc7ebeb 100644 --- a/backend/src/data/entities/ApiLogin.ts +++ b/backend/src/data/entities/ApiLogin.ts @@ -19,7 +19,10 @@ export class ApiLogin { @Column() expires_at: string; - @ManyToOne(type => ApiUserInfo, userInfo => userInfo.logins) + @ManyToOne( + type => ApiUserInfo, + userInfo => userInfo.logins, + ) @JoinColumn({ name: "user_id" }) userInfo: ApiUserInfo; } diff --git a/backend/src/data/entities/ApiPermissionAssignment.ts b/backend/src/data/entities/ApiPermissionAssignment.ts index b97ce26b..c454206c 100644 --- a/backend/src/data/entities/ApiPermissionAssignment.ts +++ b/backend/src/data/entities/ApiPermissionAssignment.ts @@ -18,7 +18,10 @@ export class ApiPermissionAssignment { @Column("simple-array") permissions: string[]; - @ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissionAssignments) + @ManyToOne( + type => ApiUserInfo, + userInfo => userInfo.permissionAssignments, + ) @JoinColumn({ name: "target_id" }) userInfo: ApiUserInfo; } diff --git a/backend/src/data/entities/ApiUserInfo.ts b/backend/src/data/entities/ApiUserInfo.ts index 9d36eb2c..e546db60 100644 --- a/backend/src/data/entities/ApiUserInfo.ts +++ b/backend/src/data/entities/ApiUserInfo.ts @@ -20,9 +20,15 @@ export class ApiUserInfo { @Column() updated_at: string; - @OneToMany(type => ApiLogin, login => login.userInfo) + @OneToMany( + type => ApiLogin, + login => login.userInfo, + ) logins: ApiLogin[]; - @OneToMany(type => ApiPermissionAssignment, p => p.userInfo) + @OneToMany( + type => ApiPermissionAssignment, + p => p.userInfo, + ) permissionAssignments: ApiPermissionAssignment[]; } diff --git a/backend/src/data/entities/Case.ts b/backend/src/data/entities/Case.ts index 88a920e3..5b65e75a 100644 --- a/backend/src/data/entities/Case.ts +++ b/backend/src/data/entities/Case.ts @@ -29,6 +29,9 @@ export class Case { @Column() pp_name: string; - @OneToMany(type => CaseNote, note => note.case) + @OneToMany( + type => CaseNote, + note => note.case, + ) notes: CaseNote[]; } diff --git a/backend/src/data/entities/CaseNote.ts b/backend/src/data/entities/CaseNote.ts index 109f72de..4541717c 100644 --- a/backend/src/data/entities/CaseNote.ts +++ b/backend/src/data/entities/CaseNote.ts @@ -15,7 +15,10 @@ export class CaseNote { @Column() created_at: string; - @ManyToOne(type => Case, theCase => theCase.notes) + @ManyToOne( + type => Case, + theCase => theCase.notes, + ) @JoinColumn({ name: "case_id" }) case: Case; } diff --git a/backend/src/data/entities/ReactionRole.ts b/backend/src/data/entities/ReactionRole.ts index ebefd1a0..38cb256d 100644 --- a/backend/src/data/entities/ReactionRole.ts +++ b/backend/src/data/entities/ReactionRole.ts @@ -19,4 +19,6 @@ export class ReactionRole { emoji: string; @Column() role_id: string; + + @Column() is_exclusive: boolean; } diff --git a/backend/src/data/entities/Reminder.ts b/backend/src/data/entities/Reminder.ts index a069ddcf..d7e33972 100644 --- a/backend/src/data/entities/Reminder.ts +++ b/backend/src/data/entities/Reminder.ts @@ -15,4 +15,6 @@ export class Reminder { @Column() remind_at: string; @Column() body: string; + + @Column() created_at: string; } diff --git a/backend/src/data/entities/ScheduledPost.ts b/backend/src/data/entities/ScheduledPost.ts index e5592f51..3295c6b1 100644 --- a/backend/src/data/entities/ScheduledPost.ts +++ b/backend/src/data/entities/ScheduledPost.ts @@ -22,5 +22,14 @@ export class ScheduledPost { @Column() post_at: string; + /** + * How often to post the message, in milliseconds + */ + @Column() repeat_interval: number; + + @Column() repeat_until: string; + + @Column() repeat_times: number; + @Column() enable_mentions: boolean; } diff --git a/backend/src/data/entities/Starboard.ts b/backend/src/data/entities/Starboard.ts deleted file mode 100644 index af170f48..00000000 --- a/backend/src/data/entities/Starboard.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm"; -import { CaseNote } from "./CaseNote"; -import { StarboardMessage } from "./StarboardMessage"; - -@Entity("starboards") -export class Starboard { - @Column() - @PrimaryColumn() - id: number; - - @Column() guild_id: string; - - @Column() channel_id: string; - - @Column() channel_whitelist: string; - - @Column() emoji: string; - - @Column() reactions_required: number; - - @OneToMany(type => StarboardMessage, msg => msg.starboard) - starboardMessages: StarboardMessage[]; -} diff --git a/backend/src/data/entities/StarboardMessage.ts b/backend/src/data/entities/StarboardMessage.ts index 0b1b37a9..50c9c241 100644 --- a/backend/src/data/entities/StarboardMessage.ts +++ b/backend/src/data/entities/StarboardMessage.ts @@ -1,23 +1,20 @@ import { Entity, Column, PrimaryColumn, OneToMany, ManyToOne, JoinColumn, OneToOne } from "typeorm"; -import { Starboard } from "./Starboard"; -import { Case } from "./Case"; import { SavedMessage } from "./SavedMessage"; @Entity("starboard_messages") export class StarboardMessage { @Column() - @PrimaryColumn() - starboard_id: number; + message_id: string; @Column() @PrimaryColumn() - message_id: string; + starboard_message_id: string; - @Column() starboard_message_id: string; + @Column() + starboard_channel_id: string; - @ManyToOne(type => Starboard, sb => sb.starboardMessages) - @JoinColumn({ name: "starboard_id" }) - starboard: Starboard; + @Column() + guild_id: string; @OneToOne(type => SavedMessage) @JoinColumn({ name: "message_id" }) diff --git a/backend/src/data/entities/StarboardReaction.ts b/backend/src/data/entities/StarboardReaction.ts new file mode 100644 index 00000000..020b3a03 --- /dev/null +++ b/backend/src/data/entities/StarboardReaction.ts @@ -0,0 +1,22 @@ +import { Entity, Column, PrimaryColumn, JoinColumn, OneToOne } from "typeorm"; +import { SavedMessage } from "./SavedMessage"; + +@Entity("starboard_reactions") +export class StarboardReaction { + @Column() + @PrimaryColumn() + id: string; + + @Column() + guild_id: string; + + @Column() + message_id: string; + + @Column() + reactor_id: string; + + @OneToOne(type => SavedMessage) + @JoinColumn({ name: "message_id" }) + message: SavedMessage; +} diff --git a/backend/src/data/entities/StatValue.ts b/backend/src/data/entities/StatValue.ts new file mode 100644 index 00000000..78978a28 --- /dev/null +++ b/backend/src/data/entities/StatValue.ts @@ -0,0 +1,20 @@ +import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm"; + +@Entity("stats") +export class StatValue { + @Column() + @PrimaryColumn() + id: string; + + @Column() + guild_id: string; + + @Column() + source: string; + + @Column() key: string; + + @Column() value: number; + + @Column() created_at: string; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 8490b3be..1128735d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,33 +24,34 @@ const RECENT_DISCORD_ERROR_EXIT_THRESHOLD = 5; setInterval(() => (recentPluginErrors = Math.max(0, recentPluginErrors - 1)), 2500); setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2500); -function errorHandler(err) { - // tslint:disable:no-console - console.error(err); +if (process.env.NODE_ENV === "production") { + const errorHandler = err => { + // tslint:disable:no-console + console.error(err); - if (err instanceof PluginError) { - // Tolerate a few recent plugin errors before crashing - if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) { - console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`); + if (err instanceof PluginError) { + // Tolerate a few recent plugin errors before crashing + if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) { + console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`); + process.exit(1); + } + } else if (err instanceof DiscordRESTError || err instanceof DiscordHTTPError) { + // Discord API errors, usually safe to just log instead of crash + // We still bail if we get a ton of them in a short amount of time + if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) { + console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`); + process.exit(1); + } + } else { + // On other errors, crash immediately process.exit(1); } - } else if (err instanceof DiscordRESTError || err instanceof DiscordHTTPError) { - // Discord API errors, usually safe to just log instead of crash - // We still bail if we get a ton of them in a short amount of time - if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) { - console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`); - process.exit(1); - } - } else { - // On other errors, crash immediately - process.exit(1); - } - // tslint:enable:no-console + // tslint:enable:no-console + }; + + process.on("uncaughtException", errorHandler); } -process.on("unhandledRejection", errorHandler); -process.on("uncaughtException", errorHandler); - // Verify required Node.js version const REQUIRED_NODE_VERSION = "10.14.2"; const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10)); diff --git a/backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts b/backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts index d6de7ffd..c921a65a 100644 --- a/backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts +++ b/backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts @@ -2,8 +2,14 @@ import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeor export class AddTypeAndPermissionsToApiPermissions1573158035867 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - await queryRunner.dropPrimaryKey("api_permissions"); - await queryRunner.dropIndex("api_permissions", "IDX_5e371749d4cb4a5191f35e26f6"); + try { + await queryRunner.dropPrimaryKey("api_permissions"); + } catch (e) {} // tslint:disable-line + + const table = await queryRunner.getTable("api_permissions"); + if (table.indices.length) { + await queryRunner.dropIndex("api_permissions", table.indices[0]); + } await queryRunner.addColumn( "api_permissions", diff --git a/backend/src/migrations/1573248462469-MoveStarboardsToConfig.ts b/backend/src/migrations/1573248462469-MoveStarboardsToConfig.ts new file mode 100644 index 00000000..39313a0f --- /dev/null +++ b/backend/src/migrations/1573248462469-MoveStarboardsToConfig.ts @@ -0,0 +1,103 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn, createQueryBuilder } from "typeorm"; + +export class MoveStarboardsToConfig1573248462469 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create the new column for the channels id + const chanid_column = new TableColumn({ + name: "starboard_channel_id", + type: "bigint", + unsigned: true, + }); + await queryRunner.addColumn("starboard_messages", chanid_column); + + // Since we are removing the guild_id with the starboards table, we might want it here + const guid_column = new TableColumn({ + name: "guild_id", + type: "bigint", + unsigned: true, + }); + await queryRunner.addColumn("starboard_messages", guid_column); + + // Migrate the old starboard_id to the new starboard_channel_id + await queryRunner.query(` + UPDATE starboard_messages AS sm + JOIN starboards AS sb + ON sm.starboard_id = sb.id + SET sm.starboard_channel_id = sb.channel_id, sm.guild_id = sb.guild_id; + `); + + // Drop the starboard_id column as it is now obsolete + await queryRunner.dropColumn("starboard_messages", "starboard_id"); + // Set new Primary Key + await queryRunner.dropPrimaryKey("starboard_messages"); + await queryRunner.createPrimaryKey("starboard_messages", ["starboard_message_id"]); + // Finally, drop the starboards channel as it is now obsolete + await queryRunner.dropTable("starboards", true); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("starboard_messages", "starboard_channel_id"); + await queryRunner.dropColumn("starboard_messages", "guild_id"); + + const sbId = new TableColumn({ + name: "starboard_id", + type: "int", + unsigned: true, + }); + await queryRunner.addColumn("starboard_messages", sbId); + + await queryRunner.dropPrimaryKey("starboard_messages"); + await queryRunner.createPrimaryKey("starboard_messages", ["starboard_id", "message_id"]); + + await queryRunner.createTable( + new Table({ + name: "starboards", + columns: [ + { + name: "id", + type: "int", + unsigned: true, + isGenerated: true, + generationStrategy: "increment", + isPrimary: true, + }, + { + name: "guild_id", + type: "bigint", + unsigned: true, + }, + { + name: "channel_id", + type: "bigint", + unsigned: true, + }, + { + name: "channel_whitelist", + type: "text", + isNullable: true, + default: null, + }, + { + name: "emoji", + type: "varchar", + length: "64", + }, + { + name: "reactions_required", + type: "smallint", + unsigned: true, + }, + ], + indices: [ + { + columnNames: ["guild_id", "emoji"], + }, + { + columnNames: ["guild_id", "channel_id"], + isUnique: true, + }, + ], + }), + ); + } +} diff --git a/backend/src/migrations/1573248794313-CreateStarboardReactionsTable.ts b/backend/src/migrations/1573248794313-CreateStarboardReactionsTable.ts new file mode 100644 index 00000000..cd9ec5bc --- /dev/null +++ b/backend/src/migrations/1573248794313-CreateStarboardReactionsTable.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateStarboardReactionsTable1573248794313 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "starboard_reactions", + columns: [ + { + name: "id", + type: "int", + isGenerated: true, + generationStrategy: "increment", + isPrimary: true, + }, + { + name: "guild_id", + type: "bigint", + unsigned: true, + }, + { + name: "message_id", + type: "bigint", + unsigned: true, + }, + { + name: "reactor_id", + type: "bigint", + unsigned: true, + }, + ], + indices: [ + { + columnNames: ["reactor_id", "message_id"], + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("starboard_reactions", true, false, true); + } +} diff --git a/backend/src/migrations/1575145703039-AddIsExclusiveToReactionRoles.ts b/backend/src/migrations/1575145703039-AddIsExclusiveToReactionRoles.ts new file mode 100644 index 00000000..141aeef3 --- /dev/null +++ b/backend/src/migrations/1575145703039-AddIsExclusiveToReactionRoles.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class AddIsExclusiveToReactionRoles1575145703039 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "reaction_roles", + new TableColumn({ + name: "is_exclusive", + type: "tinyint", + unsigned: true, + default: 0, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("reaction_roles", "is_exclusive"); + } +} diff --git a/backend/src/migrations/1575199835233-CreateStatsTable.ts b/backend/src/migrations/1575199835233-CreateStatsTable.ts new file mode 100644 index 00000000..d6c6a8d7 --- /dev/null +++ b/backend/src/migrations/1575199835233-CreateStatsTable.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateStatsTable1575199835233 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "stats", + columns: [ + { + name: "id", + type: "bigint", + unsigned: true, + isPrimary: true, + generationStrategy: "increment", + }, + { + name: "guild_id", + type: "bigint", + unsigned: true, + }, + { + name: "source", + type: "varchar", + length: "64", + collation: "ascii_bin", + }, + { + name: "key", + type: "varchar", + length: "64", + collation: "ascii_bin", + }, + { + name: "value", + type: "integer", + unsigned: true, + }, + { + name: "created_at", + type: "datetime", + default: "NOW()", + }, + ], + indices: [ + { + columnNames: ["guild_id", "source", "key"], + }, + { + columnNames: ["created_at"], + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("stats"); + } +} diff --git a/backend/src/migrations/1575230079526-AddRepeatColumnsToScheduledPosts.ts b/backend/src/migrations/1575230079526-AddRepeatColumnsToScheduledPosts.ts new file mode 100644 index 00000000..4a1b735b --- /dev/null +++ b/backend/src/migrations/1575230079526-AddRepeatColumnsToScheduledPosts.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class AddRepeatColumnsToScheduledPosts1575230079526 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns("scheduled_posts", [ + new TableColumn({ + name: "repeat_interval", + type: "integer", + unsigned: true, + isNullable: true, + }), + new TableColumn({ + name: "repeat_until", + type: "datetime", + isNullable: true, + }), + new TableColumn({ + name: "repeat_times", + type: "integer", + unsigned: true, + isNullable: true, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("scheduled_posts", "repeat_interval"); + await queryRunner.dropColumn("scheduled_posts", "repeat_until"); + await queryRunner.dropColumn("scheduled_posts", "repeat_times"); + } +} diff --git a/backend/src/migrations/1578445483917-CreateReminderCreatedAtField.ts b/backend/src/migrations/1578445483917-CreateReminderCreatedAtField.ts new file mode 100644 index 00000000..74ba8cf8 --- /dev/null +++ b/backend/src/migrations/1578445483917-CreateReminderCreatedAtField.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class CreateReminderCreatedAtField1578445483917 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "reminders", + new TableColumn({ + name: "created_at", + type: "datetime", + isNullable: false, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("reminders", "created_at"); + } +} diff --git a/backend/src/plugins/Automod.ts b/backend/src/plugins/Automod.ts index cddc2257..7fe773e2 100644 --- a/backend/src/plugins/Automod.ts +++ b/backend/src/plugins/Automod.ts @@ -2,6 +2,8 @@ import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlu import * as t from "io-ts"; import { convertDelayStringToMS, + disableInlineCode, + disableLinkPreviews, getEmojiInString, getInviteCodesInString, getRoleMentions, @@ -12,12 +14,13 @@ import { noop, SECONDS, stripObjectToScalars, + tDeepPartial, tNullable, UnknownUser, verboseChannelMention, } from "../utils"; import { configUtils, CooldownManager } from "knub"; -import { Invite, Member, TextChannel } from "eris"; +import { Member, TextChannel } from "eris"; import escapeStringRegexp from "escape-string-regexp"; import { SimpleCache } from "../SimpleCache"; import { Queue } from "../Queue"; @@ -32,6 +35,7 @@ import { GuildLogs } from "../data/GuildLogs"; import { SavedMessage } from "../data/entities/SavedMessage"; import moment from "moment-timezone"; import { renderTemplate } from "../templateFormatter"; +import { transliterate } from "transliteration"; import Timeout = NodeJS.Timeout; type MessageInfo = { channelId: string; messageId: string }; @@ -46,23 +50,26 @@ type TextTriggerWithMultipleMatchTypes = { }; interface TriggerMatchResult { + trigger: string; type: string; } -interface MessageTextTriggerMatchResult extends TriggerMatchResult { +interface MessageTextTriggerMatchResult extends TriggerMatchResult { type: "message" | "embed"; str: string; userId: string; messageInfo: MessageInfo; + matchedValue: T; } -interface OtherTextTriggerMatchResult extends TriggerMatchResult { +interface OtherTextTriggerMatchResult extends TriggerMatchResult { type: "username" | "nickname" | "visiblename" | "customstatus"; str: string; userId: string; + matchedValue: T; } -type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult; +type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult; interface TextSpamTriggerMatchResult extends TriggerMatchResult { type: "textspam"; @@ -93,13 +100,16 @@ type AnyTriggerMatchResult = | OtherSpamTriggerMatchResult; /** - * TRIGGERS + * CONFIG SCHEMA FOR TRIGGERS */ const MatchWordsTrigger = t.type({ words: t.array(t.string), case_sensitive: t.boolean, only_full_words: t.boolean, + normalize: t.boolean, + loose_matching: t.boolean, + loose_matching_threshold: t.number, match_messages: t.boolean, match_embeds: t.boolean, match_visible_names: t.boolean, @@ -108,10 +118,12 @@ const MatchWordsTrigger = t.type({ match_custom_status: t.boolean, }); type TMatchWordsTrigger = t.TypeOf; -const defaultMatchWordsTrigger: TMatchWordsTrigger = { - words: [], +const defaultMatchWordsTrigger: Partial = { case_sensitive: false, only_full_words: true, + normalize: false, + loose_matching: false, + loose_matching_threshold: 4, match_messages: true, match_embeds: true, match_visible_names: false, @@ -123,6 +135,7 @@ const defaultMatchWordsTrigger: TMatchWordsTrigger = { const MatchRegexTrigger = t.type({ patterns: t.array(TSafeRegex), case_sensitive: t.boolean, + normalize: t.boolean, match_messages: t.boolean, match_embeds: t.boolean, match_visible_names: t.boolean, @@ -133,6 +146,7 @@ const MatchRegexTrigger = t.type({ type TMatchRegexTrigger = t.TypeOf; const defaultMatchRegexTrigger: Partial = { case_sensitive: false, + normalize: false, match_messages: true, match_embeds: true, match_visible_names: false, @@ -220,7 +234,7 @@ const VoiceMoveSpamTrigger = BaseSpamTrigger; type TVoiceMoveSpamTrigger = t.TypeOf; /** - * ACTIONS + * CONFIG SCHEMA FOR ACTIONS */ const CleanAction = t.boolean; @@ -253,6 +267,9 @@ const ChangeNicknameAction = t.type({ const LogAction = t.boolean; +const AddRolesAction = t.array(t.string); +const RemoveRolesAction = t.array(t.string); + /** * FULL CONFIG SCHEMA */ @@ -287,6 +304,8 @@ const Rule = t.type({ alert: tNullable(AlertAction), change_nickname: tNullable(ChangeNicknameAction), log: tNullable(LogAction), + add_roles: tNullable(AddRolesAction), + remove_roles: tNullable(RemoveRolesAction), }), cooldown: tNullable(t.string), }); @@ -297,6 +316,8 @@ const ConfigSchema = t.type({ }); type TConfigSchema = t.TypeOf; +const PartialConfigSchema = tDeepPartial(ConfigSchema); + /** * DEFAULTS */ @@ -361,6 +382,13 @@ const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES; const inviteCache = new SimpleCache(10 * MINUTES); +/** + * General plugin flow: + * - When a message is posted: + * 1. Run logRecentActionsForMessage() -- used for detecting spam + * 2. Run matchRuleToMessage() for each automod rule. This checks if any triggers in the rule match the message. + * 3. If a rule matched, run applyActionsOnMatch() for that rule/match + */ export class AutomodPlugin extends ZeppelinPlugin { public static pluginName = "automod"; public static configSchema = ConfigSchema; @@ -499,12 +527,10 @@ export class AutomodPlugin extends ZeppelinPlugin { protected archives: GuildArchives; protected guildLogs: GuildLogs; - protected static preprocessStaticConfig(config) { - if (config.rules && typeof config.rules === "object") { + protected static preprocessStaticConfig(config: t.TypeOf) { + if (config.rules) { // Loop through each rule for (const [name, rule] of Object.entries(config.rules)) { - if (rule == null || typeof rule !== "object") continue; - rule["name"] = name; // If the rule doesn't have an explicitly set "enabled" property, set it to true @@ -513,12 +539,11 @@ export class AutomodPlugin extends ZeppelinPlugin { } // Loop through the rule's triggers - if (rule["triggers"] != null && Array.isArray(rule["triggers"])) { + if (rule["triggers"]) { for (const trigger of rule["triggers"]) { - if (trigger == null || typeof trigger !== "object") continue; // Apply default config to the triggers used in this rule for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) { - if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") { + if (trigger[defaultTriggerName]) { trigger[defaultTriggerName] = configUtils.mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]); } } @@ -526,7 +551,7 @@ export class AutomodPlugin extends ZeppelinPlugin { } // Enable logging of automod actions by default - if (rule["actions"] && typeof rule["actions"] === "object") { + if (rule["actions"]) { if (rule["actions"]["log"] == null) { rule["actions"]["log"] = true; } @@ -583,63 +608,92 @@ export class AutomodPlugin extends ZeppelinPlugin { clearInterval(this.recentNicknameChangesClearInterval); } - protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean { + /** + * @return Matched word + */ + protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): null | string { + if (trigger.normalize) { + str = transliterate(str); + } + + const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64); + for (const word of trigger.words) { - const pattern = trigger.only_full_words ? `\\b${escapeStringRegexp(word)}\\b` : escapeStringRegexp(word); + // When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other + // characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a + let pattern = trigger.loose_matching + ? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`) + : escapeStringRegexp(word); + + if (trigger.only_full_words) { + pattern = `\\b${pattern}\\b`; + } const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); const test = regex.test(str); - if (test) return true; + if (test) return word; } - return false; + return null; } - protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): boolean { + /** + * @return Matched regex pattern + */ + protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): null | string { + if (trigger.normalize) { + str = transliterate(str); + } + // TODO: Time limit regexes for (const pattern of trigger.patterns) { const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); const test = regex.test(str); - if (test) return true; + if (test) return regex.source; } - return false; + return null; } - protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise { + /** + * @return Matched invite code + */ + protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise { const inviteCodes = getInviteCodesInString(str); - if (inviteCodes.length === 0) return false; + if (inviteCodes.length === 0) return null; const uniqueInviteCodes = Array.from(new Set(inviteCodes)); for (const code of uniqueInviteCodes) { if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) { - return true; + return code; } if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) { - return true; + return code; } } - const invites: Array = await Promise.all(uniqueInviteCodes.map(code => this.resolveInvite(code))); - - for (const invite of invites) { - // Always match on unknown invites - if (!invite) return true; + for (const inviteCode of uniqueInviteCodes) { + const invite = await this.resolveInvite(inviteCode); + if (!invite) return inviteCode; if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) { - return true; + return inviteCode; } if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) { - return true; + return inviteCode; } } - return false; + return null; } - protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): boolean { + /** + * @return Matched link + */ + protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): null | string { const links = getUrlsInString(str, true); + for (const link of links) { const normalizedHostname = link.hostname.toLowerCase(); @@ -647,10 +701,10 @@ export class AutomodPlugin extends ZeppelinPlugin { for (const domain of trigger.include_domains) { const normalizedDomain = domain.toLowerCase(); if (normalizedDomain === normalizedHostname) { - return true; + return domain; } if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { - return true; + return domain; } } } @@ -659,25 +713,25 @@ export class AutomodPlugin extends ZeppelinPlugin { for (const domain of trigger.exclude_domains) { const normalizedDomain = domain.toLowerCase(); if (normalizedDomain === normalizedHostname) { - return false; + return null; } if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { - return false; + return null; } } - return true; + return link.toString(); } } - return false; + return null; } protected matchTextSpamTrigger( recentActionType: RecentActionType, trigger: TBaseTextSpamTrigger, msg: SavedMessage, - ): TextSpamTriggerMatchResult { + ): Partial { const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within); const recentActions = trigger.per_channel ? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since) @@ -699,69 +753,85 @@ export class AutomodPlugin extends ZeppelinPlugin { return null; } - protected async matchMultipleTextTypesOnMessage( + protected async matchMultipleTextTypesOnMessage( trigger: TextTriggerWithMultipleMatchTypes, msg: SavedMessage, - cb, - ): Promise { + matchFn: (str: string) => T | Promise | null, + ): Promise>> { const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id }; const member = this.guild.members.get(msg.user_id); if (trigger.match_messages) { const str = msg.data.content; - const match = await cb(str); - if (match) return { type: "message", str, userId: msg.user_id, messageInfo }; + const matchResult = await matchFn(str); + if (matchResult) { + return { type: "message", str, userId: msg.user_id, messageInfo, matchedValue: matchResult }; + } } if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) { const str = JSON.stringify(msg.data.embeds[0]); - const match = await cb(str); - if (match) return { type: "embed", str, userId: msg.user_id, messageInfo }; + const matchResult = await matchFn(str); + if (matchResult) { + return { type: "embed", str, userId: msg.user_id, messageInfo, matchedValue: matchResult }; + } } if (trigger.match_visible_names) { const str = member.nick || msg.data.author.username; - const match = await cb(str); - if (match) return { type: "visiblename", str, userId: msg.user_id }; + const matchResult = await matchFn(str); + if (matchResult) { + return { type: "visiblename", str, userId: msg.user_id, matchedValue: matchResult }; + } } if (trigger.match_usernames) { const str = `${msg.data.author.username}#${msg.data.author.discriminator}`; - const match = await cb(str); - if (match) return { type: "username", str, userId: msg.user_id }; + const matchResult = await matchFn(str); + if (matchResult) { + return { type: "username", str, userId: msg.user_id, matchedValue: matchResult }; + } } if (trigger.match_nicknames && member.nick) { const str = member.nick; - const match = await cb(str); - if (match) return { type: "nickname", str, userId: msg.user_id }; + const matchResult = await matchFn(str); + if (matchResult) { + return { type: "nickname", str, userId: msg.user_id, matchedValue: matchResult }; + } } // type 4 = custom status if (trigger.match_custom_status && member.game && member.game.type === 4) { const str = member.game.state; - const match = await cb(str); - if (match) return { type: "customstatus", str, userId: msg.user_id }; + const matchResult = await matchFn(str); + if (matchResult) { + return { type: "customstatus", str, userId: msg.user_id, matchedValue: matchResult }; + } } return null; } - protected async matchMultipleTextTypesOnMember( + protected async matchMultipleTextTypesOnMember( trigger: TextTriggerWithMultipleMatchTypes, member: Member, - cb, - ): Promise { + matchFn: (str: string) => T | Promise | null, + ): Promise>> { if (trigger.match_usernames) { const str = `${member.user.username}#${member.user.discriminator}`; - const match = await cb(str); - if (match) return { type: "username", str, userId: member.id }; + const matchResult = await matchFn(str); + if (matchResult) { + return { type: "username", str, userId: member.id, matchedValue: matchResult }; + } } if (trigger.match_nicknames && member.nick) { const str = member.nick; - const match = await cb(str); - if (match) return { type: "nickname", str, userId: member.id }; + const matchResult = await matchFn(str); + if (matchResult) { + return { type: "nickname", str, userId: member.id, matchedValue: matchResult }; + } } return null; @@ -781,63 +851,63 @@ export class AutomodPlugin extends ZeppelinPlugin { const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => { return this.evaluateMatchWordsTrigger(trigger.match_words, str); }); - if (match) return match; + if (match) return { ...match, trigger: "match_words" } as TextTriggerMatchResult; } if (trigger.match_regex) { const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => { return this.evaluateMatchRegexTrigger(trigger.match_regex, str); }); - if (match) return match; + if (match) return { ...match, trigger: "match_regex" } as TextTriggerMatchResult; } if (trigger.match_invites) { const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => { return this.evaluateMatchInvitesTrigger(trigger.match_invites, str); }); - if (match) return match; + if (match) return { ...match, trigger: "match_invites" } as TextTriggerMatchResult; } if (trigger.match_links) { const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => { return this.evaluateMatchLinksTrigger(trigger.match_links, str); }); - if (match) return match; + if (match) return { ...match, trigger: "match_links" } as TextTriggerMatchResult; } if (trigger.message_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg); - if (match) return match; + if (match) return { ...match, trigger: "message_spam" } as TextSpamTriggerMatchResult; } if (trigger.mention_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg); - if (match) return match; + if (match) return { ...match, trigger: "mention_spam" } as TextSpamTriggerMatchResult; } if (trigger.link_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg); - if (match) return match; + if (match) return { ...match, trigger: "link_spam" } as TextSpamTriggerMatchResult; } if (trigger.attachment_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg); - if (match) return match; + if (match) return { ...match, trigger: "attachment_spam" } as TextSpamTriggerMatchResult; } if (trigger.emoji_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg); - if (match) return match; + if (match) return { ...match, trigger: "emoji_spam" } as TextSpamTriggerMatchResult; } if (trigger.line_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg); - if (match) return match; + if (match) return { ...match, trigger: "line_spam" } as TextSpamTriggerMatchResult; } if (trigger.character_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg); - if (match) return match; + if (match) return { ...match, trigger: "character_spam" } as TextSpamTriggerMatchResult; } } @@ -1047,90 +1117,23 @@ export class AutomodPlugin extends ZeppelinPlugin { }); } + /** + * Apply the actions of the specified rule on the matched message/member + */ protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) { - const actionsTaken = []; - - let matchSummary = null; - let caseExtraNote = null; - - if (rule.cooldown) { - let cooldownKey = rule.name + "-"; - - if (matchResult.type === "textspam") { - cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId; - } else if (matchResult.type === "message" || matchResult.type === "embed") { - cooldownKey += matchResult.userId; - } else if ( - matchResult.type === "username" || - matchResult.type === "nickname" || - matchResult.type === "visiblename" || - matchResult.type === "customstatus" - ) { - cooldownKey += matchResult.userId; - } else if (matchResult.type === "otherspam") { - cooldownKey += matchResult.userId; - } else { - cooldownKey = null; - } - - if (cooldownKey) { - if (this.cooldownManager.isOnCooldown(cooldownKey)) { - return; - } - - const cooldownTime = convertDelayStringToMS(rule.cooldown, "s"); - if (cooldownTime) { - this.cooldownManager.setCooldown(cooldownKey, cooldownTime); - } - } + if (rule.cooldown && this.checkAndUpdateCooldown(rule, matchResult)) { + return; } - if (matchResult.type === "textspam") { - this.activateGracePeriod(matchResult); - this.clearSpecificRecentActions( - matchResult.actionType, - matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId, - ); - } + const matchSummary = this.getMatchSummary(matchResult); - // Match summary - let matchedMessageIds = []; - if (matchResult.type === "message" || matchResult.type === "embed") { - matchedMessageIds = [matchResult.messageInfo.messageId]; - } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { - matchedMessageIds = matchResult.messageInfos.map(m => m.messageId); - } - - if (matchedMessageIds.length > 1) { - const savedMessages = await this.savedMessages.getMultiple(matchedMessageIds); - const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); - const baseUrl = this.knub.getGlobalConfig().url; - const archiveUrl = this.archives.getUrl(baseUrl, archiveId); - matchSummary = `Matched messages: <${archiveUrl}>`; - } else if (matchedMessageIds.length === 1) { - const message = await this.savedMessages.find(matchedMessageIds[0]); - const channel = this.guild.channels.get(message.channel_id); - const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``; - matchSummary = `Matched message in ${channelMention} (originally posted at **${ - message.posted_at - }**):\n${messageSummary(message)}`; - } - - if (matchResult.type === "username") { - matchSummary = `Matched username: ${matchResult.str}`; - } else if (matchResult.type === "nickname") { - matchSummary = `Matched nickname: ${matchResult.str}`; - } else if (matchResult.type === "visiblename") { - matchSummary = `Matched visible name: ${matchResult.str}`; - } else if (matchResult.type === "customstatus") { - matchSummary = `Matched custom status: ${matchResult.str}`; - } - - caseExtraNote = `Matched automod rule "${rule.name}"`; + let caseExtraNote = `Matched automod rule "${rule.name}"`; if (matchSummary) { caseExtraNote += `\n${matchSummary}`; } + const actionsTaken = []; + // Actions if (rule.actions.clean) { const messagesToDelete: Array<{ channelId: string; messageId: string }> = []; @@ -1254,6 +1257,58 @@ export class AutomodPlugin extends ZeppelinPlugin { actionsTaken.push("nickname"); } + if (rule.actions.add_roles) { + const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId]; + for (const userId of userIdsToChange) { + const member = await this.getMember(userId); + if (!member) continue; + + const memberRoles = new Set(member.roles); + for (const roleId of rule.actions.add_roles) { + memberRoles.add(roleId); + } + + if (memberRoles.size === member.roles.length) { + // No role changes + continue; + } + + 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 + } + + actionsTaken.push("add roles"); + } + + if (rule.actions.remove_roles) { + const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId]; + for (const userId of userIdsToChange) { + const member = await this.getMember(userId); + if (!member) continue; + + const memberRoles = new Set(member.roles); + for (const roleId of rule.actions.remove_roles) { + memberRoles.delete(roleId); + } + + if (memberRoles.size === member.roles.length) { + // No role changes + continue; + } + + 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 + } + + actionsTaken.push("remove roles"); + } + // Don't wait for the rest before continuing to other automod items in the queue (async () => { const user = matchResult.type !== "raidspam" ? this.getUser(matchResult.userId) : new UnknownUser(); @@ -1261,6 +1316,15 @@ export class AutomodPlugin extends ZeppelinPlugin { const safeUser = stripObjectToScalars(user); const safeUsers = users.map(u => stripObjectToScalars(u)); + const logData = { + rule: rule.name, + user: safeUser, + users: safeUsers, + actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "", + matchSummary, + }; + const logMessage = this.getLogs().getLogMessage(LogType.AUTOMOD_ACTION, logData); + if (rule.actions.alert) { const channel = this.guild.channels.get(rule.actions.alert.channel); if (channel && channel instanceof TextChannel) { @@ -1271,6 +1335,7 @@ export class AutomodPlugin extends ZeppelinPlugin { users: safeUsers, text, matchSummary, + logMessage, }); channel.createMessage(rendered); actionsTaken.push("alert"); @@ -1282,17 +1347,102 @@ export class AutomodPlugin extends ZeppelinPlugin { } if (rule.actions.log) { - this.getLogs().log(LogType.AUTOMOD_ACTION, { - rule: rule.name, - user: safeUser, - users: safeUsers, - actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "", - matchSummary, - }); + this.getLogs().log(LogType.AUTOMOD_ACTION, logData); } })(); } + /** + * Check if the rule's on cooldown and bump its usage count towards the cooldown up + * @return Whether the rule's on cooldown + */ + protected checkAndUpdateCooldown(rule: TRule, matchResult: AnyTriggerMatchResult): boolean { + let cooldownKey = rule.name + "-"; + + if (matchResult.type === "textspam") { + cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId; + } else if (matchResult.type === "message" || matchResult.type === "embed") { + cooldownKey += matchResult.userId; + } else if ( + matchResult.type === "username" || + matchResult.type === "nickname" || + matchResult.type === "visiblename" || + matchResult.type === "customstatus" + ) { + cooldownKey += matchResult.userId; + } else if (matchResult.type === "otherspam") { + cooldownKey += matchResult.userId; + } else { + cooldownKey = null; + } + + if (cooldownKey) { + if (this.cooldownManager.isOnCooldown(cooldownKey)) { + return true; + } + + const cooldownTime = convertDelayStringToMS(rule.cooldown, "s"); + if (cooldownTime) { + this.cooldownManager.setCooldown(cooldownKey, cooldownTime); + } + } + + return false; + } + + /** + * Returns a text summary for the match result for use in logs/alerts + */ + protected async getMatchSummary(matchResult: AnyTriggerMatchResult): Promise { + if (matchResult.type === "message" || matchResult.type === "embed") { + const message = await this.savedMessages.find(matchResult.messageInfo.messageId); + const channel = this.guild.channels.get(matchResult.messageInfo.channelId); + const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``; + + return trimPluginDescription(` + Matched ${this.getMatchedValueText(matchResult)} in message in ${channelMention}: + ${messageSummary(message)} + `); + } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { + const savedMessages = await this.savedMessages.getMultiple(matchResult.messageInfos.map(i => i.messageId)); + const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); + const baseUrl = this.knub.getGlobalConfig().url; + const archiveUrl = this.archives.getUrl(baseUrl, archiveId); + + return trimPluginDescription(` + Matched spam: ${disableLinkPreviews(archiveUrl)} + `); + } else if (matchResult.type === "username") { + return `Matched ${this.getMatchedValueText(matchResult)} in username: ${matchResult.str}`; + } else if (matchResult.type === "nickname") { + return `Matched ${this.getMatchedValueText(matchResult)} in nickname: ${matchResult.str}`; + } else if (matchResult.type === "visiblename") { + return `Matched ${this.getMatchedValueText(matchResult)} in visible name: ${matchResult.str}`; + } else if (matchResult.type === "customstatus") { + return `Matched ${this.getMatchedValueText(matchResult)} in custom status: ${matchResult.str}`; + } + } + + /** + * Returns a formatted version of the matched value (word, regex pattern, link, etc.) for use in the match summary + */ + protected getMatchedValueText(matchResult: TextTriggerMatchResult): string | null { + if (matchResult.trigger === "match_words") { + return `word \`${disableInlineCode(matchResult.matchedValue)}\``; + } else if (matchResult.trigger === "match_regex") { + return `regex \`${disableInlineCode(matchResult.matchedValue)}\``; + } else if (matchResult.trigger === "match_invites") { + return `invite code \`${disableInlineCode(matchResult.matchedValue)}\``; + } else if (matchResult.trigger === "match_links") { + return `link \`${disableInlineCode(matchResult.matchedValue)}\``; + } + + return typeof matchResult.matchedValue === "string" ? `\`${disableInlineCode(matchResult.matchedValue)}\`` : null; + } + + /** + * Run automod actions on new messages + */ protected onMessageCreate(msg: SavedMessage) { if (msg.is_bot) return; @@ -1311,6 +1461,7 @@ export class AutomodPlugin extends ZeppelinPlugin { const matchResult = await this.matchRuleToMessage(rule, msg); if (matchResult) { await this.applyActionsOnMatch(rule, matchResult); + break; // Don't apply multiple rules to the same message } } }); diff --git a/backend/src/plugins/LocateUser.ts b/backend/src/plugins/LocateUser.ts index 6e69410b..90351136 100644 --- a/backend/src/plugins/LocateUser.ts +++ b/backend/src/plugins/LocateUser.ts @@ -91,7 +91,8 @@ export class LocatePlugin extends ZeppelinPlugin { sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`); } - @d.command("vcalert", " [reminder:string$]", { + @d.command("vcalert", " ", { + overloads: [" ", ""], aliases: ["vca"], extra: { info: { diff --git a/backend/src/plugins/Logs.ts b/backend/src/plugins/Logs.ts index 921d87e3..a3f7cf75 100644 --- a/backend/src/plugins/Logs.ts +++ b/backend/src/plugins/Logs.ts @@ -356,9 +356,11 @@ export class LogsPlugin extends ZeppelinPlugin { async onMemberUpdate(_, member: Member, oldMember: Member) { if (!oldMember) return; + const logMember = stripObjectToScalars(member, ["user", "roles"]); + if (member.nick !== oldMember.nick) { this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, { - member, + member: logMember, oldNick: oldMember.nick != null ? oldMember.nick : "", newNick: member.nick != null ? member.nick : "", }); @@ -379,7 +381,7 @@ export class LogsPlugin extends ZeppelinPlugin { this.guildLogs.log( LogType.MEMBER_ROLE_CHANGES, { - member, + member: logMember, addedRoles: addedRoles .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) .map(r => r.name) @@ -397,7 +399,7 @@ export class LogsPlugin extends ZeppelinPlugin { this.guildLogs.log( LogType.MEMBER_ROLE_ADD, { - member, + member: logMember, roles: addedRoles .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) .map(r => r.name) @@ -411,7 +413,7 @@ export class LogsPlugin extends ZeppelinPlugin { this.guildLogs.log( LogType.MEMBER_ROLE_REMOVE, { - member, + member: logMember, roles: removedRoles .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) .map(r => r.name) diff --git a/backend/src/plugins/MessageSaver.ts b/backend/src/plugins/MessageSaver.ts index d8567b05..ae413f33 100644 --- a/backend/src/plugins/MessageSaver.ts +++ b/backend/src/plugins/MessageSaver.ts @@ -125,7 +125,10 @@ export class MessageSaverPlugin extends ZeppelinPlugin { await msg.channel.createMessage(`Saving pins from <#${args.channel.id}>...`); const pins = await args.channel.getPins(); - const { savedCount, failed } = await this.saveMessagesToDB(args.channel, pins.map(m => m.id)); + const { savedCount, failed } = await this.saveMessagesToDB( + args.channel, + pins.map(m => m.id), + ); if (failed.length) { msg.channel.createMessage( diff --git a/backend/src/plugins/ModActions.ts b/backend/src/plugins/ModActions.ts index 7d4e28dc..9e0ce862 100644 --- a/backend/src/plugins/ModActions.ts +++ b/backend/src/plugins/ModActions.ts @@ -197,7 +197,10 @@ export class ModActionsPlugin extends ZeppelinPlugin { } clearIgnoredEvent(type: IgnoredEventType, userId: any) { - this.ignoredEvents.splice(this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), 1); + this.ignoredEvents.splice( + this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), + 1, + ); } formatReasonWithAttachments(reason: string, attachments: Attachment[]) { diff --git a/backend/src/plugins/Post.ts b/backend/src/plugins/Post.ts index 6717c084..5263d450 100644 --- a/backend/src/plugins/Post.ts +++ b/backend/src/plugins/Post.ts @@ -13,6 +13,10 @@ import { deactivateMentions, createChunkedMessage, stripObjectToScalars, + isValidEmbed, + MINUTES, + StrictMessageContent, + DAYS, } from "../utils"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; @@ -23,6 +27,7 @@ import moment, { Moment } from "moment-timezone"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; import * as t from "io-ts"; +import humanizeDuration from "humanize-duration"; const ConfigSchema = t.type({ can_post: t.boolean, @@ -33,9 +38,13 @@ const fsp = fs.promises; const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/; -const SCHEDULED_POST_CHECK_INTERVAL = 15 * SECONDS; +const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS; const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50; +const MIN_REPEAT_TIME = 5 * MINUTES; +const MAX_REPEAT_TIME = Math.pow(2, 32); +const MAX_REPEAT_UNTIL = moment().add(100, "years"); + export class PostPlugin extends ZeppelinPlugin { public static pluginName = "post"; public static configSchema = ConfigSchema; @@ -142,17 +151,25 @@ export class PostPlugin extends ZeppelinPlugin { } protected parseScheduleTime(str): Moment { - const dtMatch = str.match(/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}(:\d{1,2})?$/); - if (dtMatch) { - const dt = moment(str, dtMatch[1] ? "YYYY-MM-DD H:m:s" : "YYYY-MM-DD H:m"); - return dt; + const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss"); + if (dt1 && dt1.isValid()) return dt1; + + const dt2 = moment(str, "YYYY-MM-DD HH:mm"); + if (dt2 && dt2.isValid()) return dt2; + + const date = moment(str, "YYYY-MM-DD"); + if (date && date.isValid()) return date; + + const t1 = moment(str, "HH:mm:ss"); + if (t1 && t1.isValid()) { + if (t1.isBefore(moment())) t1.add(1, "day"); + return t1; } - const tMatch = str.match(/^\d{1,2}:\d{1,2}(:\d{1,2})?$/); - if (tMatch) { - const dt = moment(str, tMatch[1] ? "H:m:s" : "H:m"); - if (dt.isBefore(moment())) dt.add(1, "day"); - return dt; + const t2 = moment(str, "HH:mm"); + if (t2 && t2.isValid()) { + if (t2.isBefore(moment())) t2.add(1, "day"); + return t2; } const delayStringMS = convertDelayStringToMS(str, "m"); @@ -194,12 +211,205 @@ export class PostPlugin extends ZeppelinPlugin { } } - await this.scheduledPosts.delete(post.id); + let shouldClear = true; + + if (post.repeat_interval) { + const nextPostAt = moment().add(post.repeat_interval, "ms"); + + if (post.repeat_until) { + const repeatUntil = moment(post.repeat_until, DBDateFormat); + if (nextPostAt.isSameOrBefore(repeatUntil)) { + await this.scheduledPosts.update(post.id, { + post_at: nextPostAt.format(DBDateFormat), + }); + shouldClear = false; + } + } else if (post.repeat_times) { + if (post.repeat_times > 1) { + await this.scheduledPosts.update(post.id, { + post_at: nextPostAt.format(DBDateFormat), + repeat_times: post.repeat_times - 1, + }); + shouldClear = false; + } + } + } + + if (shouldClear) { + await this.scheduledPosts.delete(post.id); + } } this.scheduledPostLoopTimeout = setTimeout(() => this.scheduledPostLoop(), SCHEDULED_POST_CHECK_INTERVAL); } + /** + * Since !post and !post_embed have a lot of overlap for post scheduling, repeating, etc., that functionality is abstracted out to here + */ + async actualPostCmd( + msg: Message, + targetChannel: Channel, + content: StrictMessageContent, + opts?: { + "enable-mentions"?: boolean; + schedule?: string; + repeat?: number; + "repeat-until"?: string; + "repeat-times"?: number; + }, + ) { + if (!(targetChannel instanceof TextChannel)) { + msg.channel.createMessage(errorMessage("Channel is not a text channel")); + return; + } + + if (content == null && msg.attachments.length === 0) { + msg.channel.createMessage(errorMessage("Message content or attachment required")); + return; + } + + if (opts.repeat) { + if (opts.repeat < MIN_REPEAT_TIME) { + return this.sendErrorMessage(msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`); + } + if (opts.repeat > MAX_REPEAT_TIME) { + return this.sendErrorMessage(msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); + } + } + + // If this is a scheduled or repeated post, figure out the next post date + let postAt; + if (opts.schedule) { + // Schedule the post to be posted later + postAt = this.parseScheduleTime(opts.schedule); + if (!postAt) { + return this.sendErrorMessage(msg.channel, "Invalid schedule time"); + } + } else if (opts.repeat) { + postAt = moment().add(opts.repeat, "ms"); + } + + // For repeated posts, make sure repeat-until or repeat-times is specified + let repeatUntil: moment.Moment = null; + let repeatTimes: number = null; + let repeatDetailsStr: string = null; + + if (opts["repeat-until"]) { + repeatUntil = this.parseScheduleTime(opts["repeat-until"]); + + // Invalid time + if (!repeatUntil) { + return this.sendErrorMessage(msg.channel, "Invalid time specified for -repeat-until"); + } + if (repeatUntil.isBefore(moment())) { + return this.sendErrorMessage(msg.channel, "You can't set -repeat-until in the past"); + } + if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { + return this.sendErrorMessage( + msg.channel, + "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", + ); + } + } else if (opts["repeat-times"]) { + repeatTimes = opts["repeat-times"]; + if (repeatTimes <= 0) { + return this.sendErrorMessage(msg.channel, "-repeat-times must be 1 or more"); + } + } + + if (repeatUntil && repeatTimes) { + return this.sendErrorMessage(msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); + } + + if (opts.repeat && !repeatUntil && !repeatTimes) { + return this.sendErrorMessage( + msg.channel, + "You must specify -repeat-until or -repeat-times for repeated messages", + ); + } + + if (opts.repeat) { + repeatDetailsStr = repeatUntil + ? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}` + : `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`; + } + + // Save schedule/repeat information in DB + if (postAt) { + if (postAt < moment()) { + return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past"); + } + + await this.scheduledPosts.create({ + author_id: msg.author.id, + author_name: `${msg.author.username}#${msg.author.discriminator}`, + channel_id: targetChannel.id, + content, + attachments: msg.attachments, + post_at: postAt.format(DBDateFormat), + enable_mentions: opts["enable-mentions"], + repeat_interval: opts.repeat, + repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null, + repeat_times: repeatTimes ?? null, + }); + + if (opts.repeat) { + this.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + repeatInterval: humanizeDuration(opts.repeat), + repeatDetails: repeatDetailsStr, + }); + } else { + this.logs.log(LogType.SCHEDULED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + }); + } + } + + // When the message isn't scheduled for later, post it immediately + if (!opts.schedule) { + await this.postMessage(targetChannel, content, msg.attachments, opts["enable-mentions"]); + } + + if (opts.repeat) { + this.logs.log(LogType.REPEATED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + repeatInterval: humanizeDuration(opts.repeat), + repeatDetails: repeatDetailsStr, + }); + } + + // Bot reply schenanigans + let successMessage = opts.schedule + ? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)` + : `Message posted in <#${targetChannel.id}>`; + + if (opts.repeat) { + successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`; + + if (repeatUntil) { + successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`; + } else if (repeatTimes) { + successMessage += `, ${repeatTimes} times in total`; + } + + successMessage += "."; + } + + if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) { + this.sendSuccessMessage(msg.channel, successMessage); + } + } + /** * COMMAND: Post a regular text message as the bot to the specified channel */ @@ -207,66 +417,40 @@ export class PostPlugin extends ZeppelinPlugin { options: [ { name: "enable-mentions", - type: "bool", + isSwitch: true, }, { name: "schedule", type: "string", }, + { + name: "repeat", + type: "delay", + }, + { + name: "repeat-until", + type: "string", + }, + { + name: "repeat-times", + type: "number", + }, ], }) @d.permission("can_post") async postCmd( msg: Message, - args: { channel: Channel; content?: string; "enable-mentions": boolean; schedule?: string }, + args: { + channel: Channel; + content?: string; + "enable-mentions": boolean; + schedule?: string; + repeat?: number; + "repeat-until"?: string; + "repeat-times"?: number; + }, ) { - if (!(args.channel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Channel is not a text channel")); - return; - } - - if (args.content == null && msg.attachments.length === 0) { - msg.channel.createMessage(errorMessage("Text content or attachment required")); - return; - } - - if (args.schedule) { - // Schedule the post to be posted later - const postAt = this.parseScheduleTime(args.schedule); - if (!postAt) { - return this.sendErrorMessage(msg.channel, "Invalid schedule time"); - } - - if (postAt < moment()) { - return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past"); - } - - await this.scheduledPosts.create({ - author_id: msg.author.id, - author_name: `${msg.author.username}#${msg.author.discriminator}`, - channel_id: args.channel.id, - content: { content: args.content }, - attachments: msg.attachments, - post_at: postAt.format(DBDateFormat), - enable_mentions: args["enable-mentions"], - }); - this.sendSuccessMessage( - msg.channel, - `Message scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`, - ); - this.logs.log(LogType.SCHEDULED_MESSAGE, { - author: stripObjectToScalars(msg.author), - channel: stripObjectToScalars(args.channel), - date: postAt.format("YYYY-MM-DD"), - time: postAt.format("HH:mm:ss"), - }); - } else { - // Post the message immediately - await this.postMessage(args.channel, args.content, msg.attachments, args["enable-mentions"]); - if (args.channel.id !== msg.channel.id) { - this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`); - } - } + this.actualPostCmd(msg, args.channel, { content: args.content }, args); } /** @@ -278,6 +462,19 @@ export class PostPlugin extends ZeppelinPlugin { { name: "content", type: "string" }, { name: "color", type: "string" }, { name: "schedule", type: "string" }, + { name: "raw", isSwitch: true, shortcut: "r" }, + { + name: "repeat", + type: "delay", + }, + { + name: "repeat-until", + type: "string", + }, + { + name: "repeat-times", + type: "number", + }, ], }) @d.permission("can_post") @@ -290,13 +487,12 @@ export class PostPlugin extends ZeppelinPlugin { content?: string; color?: string; schedule?: string; + raw?: boolean; + repeat?: number; + "repeat-until"?: string; + "repeat-times"?: number; }, ) { - if (!(args.channel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Channel is not a text channel")); - return; - } - const content = args.content || args.maincontent; if (!args.title && !content) { @@ -315,59 +511,32 @@ export class PostPlugin extends ZeppelinPlugin { color = parseInt(colorMatch[1], 16); } - const embed: EmbedBase = {}; + let embed: EmbedBase = {}; if (args.title) embed.title = args.title; - if (content) embed.description = this.formatContent(content); if (color) embed.color = color; - if (args.schedule) { - // Schedule the post to be posted later - const postAt = this.parseScheduleTime(args.schedule); - if (!postAt) { - return this.sendErrorMessage(msg.channel, "Invalid schedule time"); - } + if (content) { + if (args.raw) { + let parsed; + try { + parsed = JSON.parse(content); + } catch (e) { + this.sendErrorMessage(msg.channel, "Syntax error in embed JSON"); + return; + } - if (postAt < moment()) { - return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past"); - } + if (!isValidEmbed(parsed)) { + this.sendErrorMessage(msg.channel, "Embed is not valid"); + return; + } - await this.scheduledPosts.create({ - author_id: msg.author.id, - author_name: `${msg.author.username}#${msg.author.discriminator}`, - channel_id: args.channel.id, - content: { embed }, - attachments: msg.attachments, - post_at: postAt.format(DBDateFormat), - }); - await this.sendSuccessMessage( - msg.channel, - `Embed scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`, - ); - this.logs.log(LogType.SCHEDULED_MESSAGE, { - author: stripObjectToScalars(msg.author), - channel: stripObjectToScalars(args.channel), - date: postAt.format("YYYY-MM-DD"), - time: postAt.format("HH:mm:ss"), - }); - } else { - const createdMsg = await args.channel.createMessage({ embed }); - this.savedMessages.setPermanent(createdMsg.id); - - if (msg.channel.id !== args.channel.id) { - await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`); + embed = Object.assign({}, embed, parsed); + } else { + embed.description = this.formatContent(content); } } - if (args.content) { - const prefix = this.guildConfig.prefix || "!"; - msg.channel.createMessage( - trimLines(` - <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command: - \`${prefix}post_embed -title "Some title" content goes here\` - The \`-content\` option will soon be removed in favor of this. - `), - ); - } + this.actualPostCmd(msg, args.channel, { embed }, args); } /** @@ -472,6 +641,16 @@ export class PostPlugin extends ZeppelinPlugin { const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`]; if (p.attachments.length) parts.push("*(with attachment)*"); if (p.content.embed) parts.push("*(embed)*"); + if (p.repeat_until) { + parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`); + } + if (p.repeat_times) { + parts.push( + `*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${ + p.repeat_times === 1 ? "time" : "times" + })*`, + ); + } parts.push(`*(${p.author_name})*`); return parts.join(" "); @@ -480,7 +659,7 @@ export class PostPlugin extends ZeppelinPlugin { const finalMessage = trimLines(` ${postLines.join("\n")} - Use \`scheduled_posts show \` to view a scheduled post in full + Use \`scheduled_posts \` to view a scheduled post in full Use \`scheduled_posts delete \` to delete a scheduled post `); createChunkedMessage(msg.channel, finalMessage); diff --git a/backend/src/plugins/ReactionRoles.ts b/backend/src/plugins/ReactionRoles.ts index 5bdc4c51..b07609d9 100644 --- a/backend/src/plugins/ReactionRoles.ts +++ b/backend/src/plugins/ReactionRoles.ts @@ -268,9 +268,17 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { * :zep_twitch: = 473086848831455234 * :zep_ps4: = 543184300250759188 */ - @d.command("reaction_roles", " ") + @d.command("reaction_roles", " ", { + options: [ + { + name: "exclusive", + shortcut: "e", + isSwitch: true, + }, + ], + }) @d.permission("can_manage") - async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string }) { + async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string; exclusive?: boolean }) { const savedMessage = await this.savedMessages.find(args.messageId); if (!savedMessage) { msg.channel.createMessage(errorMessage("Unknown message")); @@ -331,7 +339,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { // Save the new reaction roles to the database for (const pair of emojiRolePairs) { - await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1]); + await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive); } // Apply the reactions themselves @@ -370,6 +378,14 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji(msg.id, emoji.id || emoji.name); if (!matchingReactionRole) return; + // If the reaction role is exclusive, remove any other roles in the message first + if (matchingReactionRole.is_exclusive) { + const messageReactionRoles = await this.reactionRoles.getForMessage(msg.id); + for (const reactionRole of messageReactionRoles) { + this.addMemberPendingRoleChange(userId, "-", reactionRole.role_id); + } + } + this.addMemberPendingRoleChange(userId, "+", matchingReactionRole.role_id); } diff --git a/backend/src/plugins/Reminders.ts b/backend/src/plugins/Reminders.ts index b97dc72d..dd1767e0 100644 --- a/backend/src/plugins/Reminders.ts +++ b/backend/src/plugins/Reminders.ts @@ -70,9 +70,19 @@ export class RemindersPlugin extends ZeppelinPlugin { const channel = this.guild.channels.get(reminder.channel_id); if (channel && channel instanceof TextChannel) { try { - await channel.createMessage( - disableLinkPreviews(`<@!${reminder.user_id}> You asked me to remind you: ${reminder.body}`), - ); + // Only show created at date if one exists + if (moment(reminder.created_at).isValid()) { + const target = moment(); + const diff = target.diff(moment(reminder.created_at, "YYYY-MM-DD HH:mm:ss")); + const result = humanizeDuration(diff, { largest: 2, round: true }); + await channel.createMessage( + disableLinkPreviews( + `Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``, + ), + ); + } else { + await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`)); + } } catch (e) { // Probably random Discord internal server error or missing permissions or somesuch // Try again next round unless we've already tried to post this a bunch of times @@ -127,7 +137,13 @@ export class RemindersPlugin extends ZeppelinPlugin { } const reminderBody = args.reminder || `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`; - await this.reminders.add(msg.author.id, msg.channel.id, reminderTime.format("YYYY-MM-DD HH:mm:ss"), reminderBody); + await this.reminders.add( + msg.author.id, + msg.channel.id, + reminderTime.format("YYYY-MM-DD HH:mm:ss"), + reminderBody, + moment().format("YYYY-MM-DD HH:mm:ss"), + ); const msUntilReminder = reminderTime.diff(now); const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true }); @@ -152,7 +168,10 @@ export class RemindersPlugin extends ZeppelinPlugin { const lines = Array.from(reminders.entries()).map(([i, reminder]) => { const num = i + 1; const paddedNum = num.toString().padStart(longestNum, " "); - return `\`${paddedNum}.\` \`${reminder.remind_at}\` ${reminder.body}`; + const target = moment(reminder.remind_at, "YYYY-MM-DD HH:mm:ss"); + const diff = target.diff(moment()); + const result = humanizeDuration(diff, { largest: 2, round: true }); + return `\`${paddedNum}.\` \`${reminder.remind_at} (${result})\` ${reminder.body}`; }); createChunkedMessage(msg.channel, lines.join("\n")); diff --git a/backend/src/plugins/Roles.ts b/backend/src/plugins/Roles.ts new file mode 100644 index 00000000..2ae0db00 --- /dev/null +++ b/backend/src/plugins/Roles.ts @@ -0,0 +1,146 @@ +import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin"; +import * as t from "io-ts"; +import { stripObjectToScalars, tNullable } from "../utils"; +import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub"; +import { Attachment, Constants as ErisConstants, Guild, GuildChannel, Member, Message, TextChannel, User } from "eris"; +import { GuildLogs } from "../data/GuildLogs"; +import { LogType } from "../data/LogType"; + +const ConfigSchema = t.type({ + can_assign: t.boolean, + assignable_roles: t.array(t.string), +}); +type TConfigSchema = t.TypeOf; + +export class RolesPlugin extends ZeppelinPlugin { + public static pluginName = "roles"; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Roles", + description: trimPluginDescription(` + Enables authorised users to add and remove whitelisted roles with a command. + `), + }; + + protected logs: GuildLogs; + + onLoad() { + this.logs = new GuildLogs(this.guildId); + } + + public static getStaticDefaultOptions(): IPluginOptions { + return { + config: { + can_assign: false, + assignable_roles: [], + }, + overrides: [ + { + level: ">=50", + config: { + can_assign: true, + }, + }, + ], + }; + } + + @d.command("addrole", " [role:string$]", { + extra: { + info: { + description: "Add a role to the specified member", + }, + }, + }) + @d.permission("can_assign") + async addRoleCmd(msg: Message, args: { member: Member; role: string }) { + if (!this.canActOn(msg.member, args.member, true)) { + return this.sendErrorMessage(msg.channel, "Cannot add roles to this user: insufficient permissions"); + } + + const roleId = await this.resolveRoleId(args.role); + if (!roleId) { + return this.sendErrorMessage(msg.channel, "Invalid role id"); + } + + const config = this.getConfigForMsg(msg); + if (!config.assignable_roles.includes(roleId)) { + return this.sendErrorMessage(msg.channel, "You cannot assign that role"); + } + + // Sanity check: make sure the role is configured properly + const role = (msg.channel as GuildChannel).guild.roles.get(roleId); + if (!role) { + this.logs.log(LogType.BOT_ALERT, { + body: `Unknown role configured for 'roles' plugin: ${roleId}`, + }); + return this.sendErrorMessage(msg.channel, "You cannot assign that role"); + } + + if (args.member.roles.includes(roleId)) { + return this.sendErrorMessage(msg.channel, "Member already has that role"); + } + + this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); + + await args.member.addRole(roleId); + + this.logs.log(LogType.MEMBER_ROLE_ADD, { + member: stripObjectToScalars(args.member, ["user", "roles"]), + roles: role.name, + mod: stripObjectToScalars(msg.author), + }); + + this.sendSuccessMessage(msg.channel, "Role added to user!"); + } + + @d.command("removerole", " [role:string$]", { + extra: { + info: { + description: "Remove a role from the specified member", + }, + }, + }) + @d.permission("can_assign") + async removeRoleCmd(msg: Message, args: { member: Member; role: string }) { + if (!this.canActOn(msg.member, args.member, true)) { + return this.sendErrorMessage(msg.channel, "Cannot remove roles from this user: insufficient permissions"); + } + + const roleId = await this.resolveRoleId(args.role); + if (!roleId) { + return this.sendErrorMessage(msg.channel, "Invalid role id"); + } + + const config = this.getConfigForMsg(msg); + if (!config.assignable_roles.includes(roleId)) { + return this.sendErrorMessage(msg.channel, "You cannot remove that role"); + } + + // Sanity check: make sure the role is configured properly + const role = (msg.channel as GuildChannel).guild.roles.get(roleId); + if (!role) { + this.logs.log(LogType.BOT_ALERT, { + body: `Unknown role configured for 'roles' plugin: ${roleId}`, + }); + return this.sendErrorMessage(msg.channel, "You cannot remove that role"); + } + + if (!args.member.roles.includes(roleId)) { + return this.sendErrorMessage(msg.channel, "Member doesn't have that role"); + } + + this.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id); + + await args.member.removeRole(roleId); + + this.logs.log(LogType.MEMBER_ROLE_REMOVE, { + member: stripObjectToScalars(args.member, ["user", "roles"]), + roles: role.name, + mod: stripObjectToScalars(msg.author), + }); + + this.sendSuccessMessage(msg.channel, "Role removed from user!"); + } +} diff --git a/backend/src/plugins/Slowmode.ts b/backend/src/plugins/Slowmode.ts index 23f52c9f..3535797a 100644 --- a/backend/src/plugins/Slowmode.ts +++ b/backend/src/plugins/Slowmode.ts @@ -212,7 +212,7 @@ export class SlowmodePlugin extends ZeppelinPlugin { options: [ { name: "force", - type: "bool", + isSwitch: true, }, ], }) diff --git a/backend/src/plugins/Starboard.ts b/backend/src/plugins/Starboard.ts index 2556acc3..46868206 100644 --- a/backend/src/plugins/Starboard.ts +++ b/backend/src/plugins/Starboard.ts @@ -1,58 +1,162 @@ -import { decorators as d, waitForReply, utils as knubUtils, IBasePluginConfig, IPluginOptions } from "knub"; -import { ZeppelinPlugin } from "./ZeppelinPlugin"; -import { GuildStarboards } from "../data/GuildStarboards"; -import { GuildChannel, Message, TextChannel } from "eris"; +import { decorators as d, IPluginOptions } from "knub"; +import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin"; +import { Embed, EmbedBase, GuildChannel, Message, TextChannel } from "eris"; import { - customEmojiRegex, errorMessage, - getEmojiInString, getUrlsInString, + messageLink, noop, - snowflakeRegex, successMessage, + TDeepPartialProps, + tNullable, + tDeepPartial, + UnknownUser, + EMPTY_CHAR, } from "../utils"; -import { Starboard } from "../data/entities/Starboard"; import path from "path"; import moment from "moment-timezone"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { SavedMessage } from "../data/entities/SavedMessage"; import * as t from "io-ts"; +import { GuildStarboardMessages } from "../data/GuildStarboardMessages"; +import { StarboardMessage } from "../data/entities/StarboardMessage"; +import { GuildStarboardReactions } from "../data/GuildStarboardReactions"; + +const StarboardOpts = t.type({ + channel_id: t.string, + stars_required: t.number, + star_emoji: tNullable(t.array(t.string)), + enabled: tNullable(t.boolean), +}); +type TStarboardOpts = t.TypeOf; const ConfigSchema = t.type({ - can_manage: t.boolean, + boards: t.record(t.string, StarboardOpts), + can_migrate: t.boolean, }); type TConfigSchema = t.TypeOf; +const PartialConfigSchema = tDeepPartial(ConfigSchema); + +const defaultStarboardOpts: Partial = { + star_emoji: ["⭐"], + enabled: true, +}; + export class StarboardPlugin extends ZeppelinPlugin { public static pluginName = "starboard"; - public static showInDocs = false; public static configSchema = ConfigSchema; - protected starboards: GuildStarboards; + public static pluginInfo = { + prettyName: "Starboard", + description: trimPluginDescription(` + This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel. + `), + configurationGuide: trimPluginDescription(` + ### Note on emojis + To specify emoji in the config, you need to use the emoji's "raw form". + To obtain this, post the emoji with a backslash in front of it. + + - Example with a default emoji: "\:star:" => "⭐" + - Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>" + + ### Basic starboard + Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226). + + ~~~yml + starboard: + config: + boards: + basic: + channel_id: "604342689038729226" + stars_required: 5 + ~~~ + + ### Custom star emoji + This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji + + ~~~yml + starboard: + config: + boards: + basic: + channel_id: "604342689038729226" + star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"] + stars_required: 5 + ~~~ + + ### Limit starboard to a specific channel + This is identical to the basic starboard above, but only works from a specific channel (473087035574321152). + + ~~~yml + starboard: + config: + boards: + basic: + enabled: false # The starboard starts disabled and is then enabled in a channel override below + channel_id: "604342689038729226" + stars_required: 5 + overrides: + - channel: "473087035574321152" + config: + boards: + basic: + enabled: true + ~~~ + `), + }; + protected savedMessages: GuildSavedMessages; + protected starboardMessages: GuildStarboardMessages; + protected starboardReactions: GuildStarboardReactions; private onMessageDeleteFn; public static getStaticDefaultOptions(): IPluginOptions { return { config: { - can_manage: false, + can_migrate: false, + boards: {}, }, overrides: [ { level: ">=100", config: { - can_manage: true, + can_migrate: true, }, }, ], }; } + protected static preprocessStaticConfig(config: t.TypeOf) { + if (config.boards) { + for (const [name, opts] of Object.entries(config.boards)) { + config.boards[name] = Object.assign({}, defaultStarboardOpts, config.boards[name]); + } + } + + return config; + } + + protected getStarboardOptsForStarboardChannel(starboardChannel): TStarboardOpts[] { + const config = this.getConfigForChannel(starboardChannel); + + const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id); + configs.forEach(cfg => { + if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled; + if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji; + if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required; + }); + + return configs; + } + onLoad() { - this.starboards = GuildStarboards.getGuildInstance(this.guildId); this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); + this.starboardMessages = GuildStarboardMessages.getGuildInstance(this.guildId); + this.starboardReactions = GuildStarboardReactions.getGuildInstance(this.guildId); this.onMessageDeleteFn = this.onMessageDelete.bind(this); this.savedMessages.events.on("delete", this.onMessageDeleteFn); @@ -62,143 +166,13 @@ export class StarboardPlugin extends ZeppelinPlugin { this.savedMessages.events.off("delete", this.onMessageDeleteFn); } - /** - * An interactive setup for creating a starboard - */ - @d.command("starboard create") - @d.permission("can_manage") - async setupCmd(msg: Message) { - const cancelMsg = () => msg.channel.createMessage("Cancelled"); - - msg.channel.createMessage( - `⭐ Let's make a starboard! What channel should we use as the board? ("cancel" to cancel)`, - ); - - let starboardChannel; - do { - const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id, 60000); - if (reply.content == null || reply.content === "cancel") return cancelMsg(); - - starboardChannel = knubUtils.resolveChannel(this.guild, reply.content || ""); - if (!starboardChannel) { - msg.channel.createMessage("Invalid channel. Try again?"); - continue; - } - - const existingStarboard = await this.starboards.getStarboardByChannelId(starboardChannel.id); - if (existingStarboard) { - msg.channel.createMessage("That channel already has a starboard. Try again?"); - starboardChannel = null; - continue; - } - } while (starboardChannel == null); - - msg.channel.createMessage(`Ok. Which emoji should we use as the trigger? ("cancel" to cancel)`); - - let emoji; - do { - const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id); - if (reply.content == null || reply.content === "cancel") return cancelMsg(); - - const allEmojis = getEmojiInString(reply.content || ""); - if (!allEmojis.length) { - msg.channel.createMessage("Invalid emoji. Try again?"); - continue; - } - - emoji = allEmojis[0]; - - const customEmojiMatch = emoji.match(customEmojiRegex); - if (customEmojiMatch) { - // <:name:id> to name:id, as Eris puts them in the message reactions object - emoji = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`; - } - } while (emoji == null); - - msg.channel.createMessage( - `And how many reactions are required to immortalize a message in the starboard? ("cancel" to cancel)`, - ); - - let requiredReactions; - do { - const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id); - if (reply.content == null || reply.content === "cancel") return cancelMsg(); - - requiredReactions = parseInt(reply.content || "", 10); - - if (Number.isNaN(requiredReactions)) { - msg.channel.createMessage("Invalid number. Try again?"); - continue; - } - - if (typeof requiredReactions === "number") { - if (requiredReactions <= 0) { - msg.channel.createMessage("The number must be higher than 0. Try again?"); - continue; - } else if (requiredReactions > 65536) { - msg.channel.createMessage("The number must be smaller than 65536. Try again?"); - continue; - } - } - } while (requiredReactions == null); - - msg.channel.createMessage( - `And finally, which channels can messages be starred in? "All" for any channel. ("cancel" to cancel)`, - ); - - let channelWhitelist; - do { - const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id); - if (reply.content == null || reply.content === "cancel") return cancelMsg(); - - if (reply.content.toLowerCase() === "all") { - channelWhitelist = null; - break; - } - - channelWhitelist = reply.content.match(new RegExp(snowflakeRegex, "g")); - - let hasInvalidChannels = false; - for (const id of channelWhitelist) { - const channel = this.guild.channels.get(id); - if (!channel || !(channel instanceof TextChannel)) { - msg.channel.createMessage(`Couldn't recognize channel <#${id}> (\`${id}\`). Try again?`); - hasInvalidChannels = true; - break; - } - } - if (hasInvalidChannels) continue; - } while (channelWhitelist == null); - - await this.starboards.create(starboardChannel.id, channelWhitelist, emoji, requiredReactions); - - msg.channel.createMessage(successMessage("Starboard created!")); - } - - /** - * Deletes the starboard from the specified channel. The already-posted starboard messages are retained. - */ - @d.command("starboard delete", "") - @d.permission("can_manage") - async deleteCmd(msg: Message, args: { channelId: string }) { - const starboard = await this.starboards.getStarboardByChannelId(args.channelId); - if (!starboard) { - msg.channel.createMessage(errorMessage(`Channel <#${args.channelId}> doesn't have a starboard!`)); - return; - } - - await this.starboards.delete(starboard.channel_id); - - msg.channel.createMessage(successMessage(`Starboard deleted from <#${args.channelId}>!`)); - } - /** * When a reaction is added to a message, check if there are any applicable starboards and if the reactions reach * the required threshold. If they do, post the message in the starboard channel. */ @d.event("messageReactionAdd") @d.lock("starboardReaction") - async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }) { + async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }, userId: string) { if (!msg.author) { // Message is not cached, fetch it try { @@ -209,63 +183,80 @@ export class StarboardPlugin extends ZeppelinPlugin { } } - const emojiStr = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name; - const applicableStarboards = await this.starboards.getStarboardsByEmoji(emojiStr); + // No self-votes! + if (msg.author.id === userId) return; + + const user = await this.resolveUser(userId); + if (user instanceof UnknownUser) return; + if (user.bot) return; + + const config = this.getConfigForMemberIdAndChannelId(userId, msg.channel.id); + const applicableStarboards = Object.values(config.boards) + .filter(board => board.enabled) + // Can't star messages in the starboard channel itself + .filter(board => board.channel_id !== msg.channel.id) + // Matching emoji + .filter(board => { + return board.star_emoji.some((boardEmoji: string) => { + if (emoji.id) { + // Custom emoji + const customEmojiMatch = boardEmoji.match(/^?$/); + if (customEmojiMatch) { + return customEmojiMatch[1] === emoji.id; + } + + return boardEmoji === emoji.id; + } else { + // Unicode emoji + return emoji.name === boardEmoji; + } + }); + }); for (const starboard of applicableStarboards) { - // Can't star messages in the starboard channel itself - if (msg.channel.id === starboard.channel_id) continue; + // Save reaction into the database + await this.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop); - if (starboard.channel_whitelist) { - const allowedChannelIds = starboard.channel_whitelist.split(","); - if (!allowedChannelIds.includes(msg.channel.id)) continue; - } + // If the message has already been posted to this starboard, we don't need to do anything else + const starboardMessages = await this.starboardMessages.getMatchingStarboardMessages(starboard.channel_id, msg.id); + if (starboardMessages.length > 0) continue; - // If the message has already been posted to this starboard, we don't need to do anything else here - const existingSavedMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId( - starboard.id, - msg.id, - ); - if (existingSavedMessage) return; - - const reactionsCount = await this.countReactions(msg, emojiStr); - - if (reactionsCount >= starboard.reactions_required) { - await this.saveMessageToStarboard(msg, starboard); + const reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id); + const reactionsCount = reactions.length; + if (reactionsCount >= starboard.stars_required) { + await this.saveMessageToStarboard(msg, starboard.channel_id); } } } - /** - * Counts the specific reactions in the message, ignoring the message author - */ - async countReactions(msg: Message, reaction) { - let reactionsCount = (msg.reactions[reaction] && msg.reactions[reaction].count) || 0; + @d.event("messageReactionRemove") + async onStarboardReactionRemove(msg: Message, emoji: { id: string; name: string }, userId: string) { + await this.starboardReactions.deleteStarboardReaction(msg.id, userId); + } - // Ignore self-stars - const reactors = await msg.getReaction(reaction); - if (reactors.some(u => u.id === msg.author.id)) reactionsCount--; - - return reactionsCount; + @d.event("messageReactionRemoveAll") + async onMessageReactionRemoveAll(msg: Message) { + await this.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.id); } /** - * Saves/posts a message to the specified starboard. The message is posted as an embed and image attachments are - * included as the embed image. + * Saves/posts a message to the specified starboard. + * The message is posted as an embed and image attachments are included as the embed image. */ - async saveMessageToStarboard(msg: Message, starboard: Starboard) { - const channel = this.guild.channels.get(starboard.channel_id); + async saveMessageToStarboard(msg: Message, starboardChannelId: string) { + const channel = this.guild.channels.get(starboardChannelId); if (!channel) return; const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]"); - const embed: any = { + const embed: EmbedBase = { footer: { - text: `#${(msg.channel as GuildChannel).name} - ${time}`, + text: `#${(msg.channel as GuildChannel).name}`, }, author: { name: `${msg.author.username}#${msg.author.discriminator}`, }, + timestamp: new Date(msg.timestamp).toISOString(), }; if (msg.author.avatarURL) { @@ -276,6 +267,7 @@ export class StarboardPlugin extends ZeppelinPlugin { embed.description = msg.content; } + // Include attachments if (msg.attachments.length) { const attachment = msg.attachments[0]; const ext = path @@ -285,87 +277,96 @@ export class StarboardPlugin extends ZeppelinPlugin { if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) { embed.image = { url: attachment.url }; } - } else if (msg.content) { - const links = getUrlsInString(msg.content); - for (const link of links) { - const parts = link - .toString() - .replace(/\/$/, "") - .split("."); - const ext = parts[parts.length - 1].toLowerCase(); - - if ( - (link.hostname === "i.imgur.com" || link.hostname === "cdn.discordapp.com") && - ["jpeg", "jpg", "png", "gif", "webp"].includes(ext) - ) { - embed.image = { url: link.toString() }; - break; - } - } } - const starboardMessage = await (channel as TextChannel).createMessage({ - content: `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`, - embed, - }); - await this.starboards.createStarboardMessage(starboard.id, msg.id, starboardMessage.id); + // Include any embed images in the original message + if (msg.embeds.length && msg.embeds[0].image) { + embed.image = msg.embeds[0].image; + } + + embed.fields = [{ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }]; + + const starboardMessage = await (channel as TextChannel).createMessage({ embed }); + await this.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id); } /** * Remove a message from the specified starboard */ - async removeMessageFromStarboard(msgId: string, starboard: Starboard) { - const starboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(starboard.id, msgId); - if (!starboardMessage) return; + async removeMessageFromStarboard(msg: StarboardMessage) { + await this.bot.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop); + } - await this.bot.deleteMessage(starboard.channel_id, starboardMessage.starboard_message_id).catch(noop); - await this.starboards.deleteStarboardMessage(starboard.id, msgId); + async removeMessageFromStarboardMessages(starboard_message_id: string, channel_id: string) { + await this.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id); } /** * When a message is deleted, also delete it from any starboards it's been posted in. + * Likewise, if a starboard message (i.e. the bot message in the starboard) is deleted, remove it from the database. * This function is called in response to GuildSavedMessages events. - * TODO: When a message is removed from the starboard itself, i.e. the bot's embed is removed, also remove that message from the starboard_messages database table */ async onMessageDelete(msg: SavedMessage) { - const starboardMessages = await this.starboards.with("starboard").getStarboardMessagesByMessageId(msg.id); - if (!starboardMessages.length) return; - + // Deleted source message + const starboardMessages = await this.starboardMessages.getStarboardMessagesForMessageId(msg.id); for (const starboardMessage of starboardMessages) { - if (!starboardMessage.starboard) continue; - this.removeMessageFromStarboard(starboardMessage.message_id, starboardMessage.starboard); + this.removeMessageFromStarboard(starboardMessage); + } + + // Deleted message from the starboard + const deletedStarboardMessages = await this.starboardMessages.getStarboardMessagesForStarboardMessageId(msg.id); + if (deletedStarboardMessages.length === 0) return; + + for (const starboardMessage of deletedStarboardMessages) { + this.removeMessageFromStarboardMessages( + starboardMessage.starboard_message_id, + starboardMessage.starboard_channel_id, + ); } } - @d.command("starboard migrate_pins", " ") - async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) { - const starboard = await this.starboards.getStarboardByChannelId(args.starboardChannelId); + @d.command("starboard migrate_pins", " ", { + extra: { + info: { + description: + "Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.", + }, + }, + }) + @d.permission("can_migrate") + async migratePinsCmd(msg: Message, args: { pinChannel: GuildChannel; starboardName: string }) { + const config = await this.getConfig(); + const starboard = config.boards[args.starboardName]; if (!starboard) { - msg.channel.createMessage(errorMessage("The specified channel doesn't have a starboard!")); + this.sendErrorMessage(msg.channel, "Unknown starboard specified"); return; } - const channel = (await this.guild.channels.get(args.pinChannelId)) as GuildChannel & TextChannel; - if (!channel) { - msg.channel.createMessage(errorMessage("Could not find the specified channel to migrate pins from!")); + if (!(args.pinChannel instanceof TextChannel)) { + this.sendErrorMessage(msg.channel, "Unknown/invalid pin channel id"); return; } - msg.channel.createMessage(`Migrating pins from <#${channel.id}> to <#${args.starboardChannelId}>...`); + const starboardChannel = this.guild.channels.get(starboard.channel_id); + if (!starboardChannel || !(starboardChannel instanceof TextChannel)) { + this.sendErrorMessage(msg.channel, "Starboard has an unknown/invalid channel id"); + return; + } - const pins = await channel.getPins(); + msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`); + + const pins = await args.pinChannel.getPins(); pins.reverse(); // Migrate pins starting from the oldest message for (const pin of pins) { - const existingStarboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId( - starboard.id, + const existingStarboardMessage = await this.starboardMessages.getMatchingStarboardMessages( + starboardChannel.id, pin.id, ); - if (existingStarboardMessage) continue; - - await this.saveMessageToStarboard(pin, starboard); + if (existingStarboardMessage.length > 0) continue; + await this.saveMessageToStarboard(pin, starboardChannel.id); } - msg.channel.createMessage(successMessage("Pins migrated!")); + this.sendSuccessMessage(msg.channel, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`); } } diff --git a/backend/src/plugins/Stats.ts b/backend/src/plugins/Stats.ts new file mode 100644 index 00000000..63c12856 --- /dev/null +++ b/backend/src/plugins/Stats.ts @@ -0,0 +1,155 @@ +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import * as t from "io-ts"; +import { convertDelayStringToMS, DAYS, HOURS, tAlphanumeric, tDateTime, tDeepPartial, tDelayString } from "../utils"; +import { IPluginOptions } from "knub"; +import moment from "moment-timezone"; +import { GuildStats } from "../data/GuildStats"; +import { Message } from "eris"; +import escapeStringRegexp from "escape-string-regexp"; +import { SavedMessage } from "../data/entities/SavedMessage"; +import { GuildSavedMessages } from "../data/GuildSavedMessages"; + +const tBaseSource = t.type({ + name: tAlphanumeric, + track: t.boolean, + retention_period: tDelayString, +}); + +const tMemberMessagesSource = t.intersection([ + tBaseSource, + t.type({ + type: t.literal("member_messages"), + }), +]); +type TMemberMessagesSource = t.TypeOf; + +const tChannelMessagesSource = t.intersection([ + tBaseSource, + t.type({ + type: t.literal("channel_messages"), + }), +]); +type TChannelMessagesSource = t.TypeOf; + +const tKeywordsSource = t.intersection([ + tBaseSource, + t.type({ + type: t.literal("keywords"), + keywords: t.array(t.string), + }), +]); +type TKeywordsSource = t.TypeOf; + +const tSource = t.union([tMemberMessagesSource, tChannelMessagesSource, tKeywordsSource]); +type TSource = t.TypeOf; + +const tConfigSchema = t.type({ + sources: t.record(tAlphanumeric, tSource), +}); + +type TConfigSchema = t.TypeOf; +const tPartialConfigSchema = tDeepPartial(tConfigSchema); + +const DEFAULT_RETENTION_PERIOD = "4w"; + +export class StatsPlugin extends ZeppelinPlugin { + public static pluginName = "stats"; + public static configSchema = tConfigSchema; + public static showInDocs = false; + + protected stats: GuildStats; + protected savedMessages: GuildSavedMessages; + + private onMessageCreateFn; + private cleanStatsInterval; + + public static getStaticDefaultOptions(): IPluginOptions { + return { + config: { + sources: {}, + }, + }; + } + + protected static preprocessStaticConfig(config: t.TypeOf) { + // TODO: Limit min period, min period start date + + if (config.sources) { + for (const [key, source] of Object.entries(config.sources)) { + source.name = key; + + if (source.track == null) { + source.track = true; + } + + if (source.retention_period == null) { + source.retention_period = DEFAULT_RETENTION_PERIOD; + } + } + } + + return config; + } + + protected onLoad() { + this.stats = GuildStats.getGuildInstance(this.guildId); + this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); + + this.onMessageCreateFn = this.savedMessages.events.on("create", msg => this.onMessageCreate(msg)); + + this.cleanOldStats(); + this.cleanStatsInterval = setInterval(() => this.cleanOldStats(), 1 * DAYS); + } + + protected onUnload() { + this.savedMessages.events.off("create", this.onMessageCreateFn); + clearInterval(this.cleanStatsInterval); + } + + protected async cleanOldStats() { + const config = this.getConfig(); + for (const source of Object.values(config.sources)) { + const cutoffMS = convertDelayStringToMS(source.retention_period); + const cutoff = moment() + .subtract(cutoffMS, "ms") + .format("YYYY-MM-DD HH:mm:ss"); + await this.stats.deleteOldValues(source.name, cutoff); + } + } + + protected saveMemberMessagesStats(source: TMemberMessagesSource, msg: SavedMessage) { + this.stats.saveValue(source.name, msg.user_id, 1); + } + + protected saveChannelMessagesStats(source: TChannelMessagesSource, msg: SavedMessage) { + this.stats.saveValue(source.name, msg.channel_id, 1); + } + + protected saveKeywordsStats(source: TKeywordsSource, msg: SavedMessage) { + const content = msg.data.content; + if (!content) return; + + for (const keyword of source.keywords) { + const regex = new RegExp(`\\b${escapeStringRegexp(keyword)}\\b`, "i"); + if (content.match(regex)) { + this.stats.saveValue(source.name, "keyword", 1); + break; + } + } + } + + onMessageCreate(msg: SavedMessage) { + const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id); + for (const source of Object.values(config.sources)) { + if (!source.track) continue; + + if (source.type === "member_messages") { + this.saveMemberMessagesStats(source, msg); + } else if (source.type === "channel_messages") { + this.saveChannelMessagesStats(source, msg); + } else if (source.type === "keywords") { + this.saveKeywordsStats(source, msg); + } + } + } +} diff --git a/backend/src/plugins/Tags.ts b/backend/src/plugins/Tags.ts index abcdbde9..812162dc 100644 --- a/backend/src/plugins/Tags.ts +++ b/backend/src/plugins/Tags.ts @@ -183,8 +183,20 @@ export class TagsPlugin extends ZeppelinPlugin { msg.channel.createMessage(successMessage(`Tag set! Use it with: \`${prefix}${args.tag}\``)); } - @d.command("tag", "") - async tagSourceCmd(msg: Message, args: { tag: string }) { + @d.command("tag", "", { + options: [ + { + name: "delete", + shortcut: "d", + isSwitch: true, + }, + ], + }) + async tagSourceCmd(msg: Message, args: { tag: string; delete?: boolean }) { + if (args.delete) { + return this.deleteTagCmd(msg, { tag: args.tag }); + } + const tag = await this.tags.find(args.tag); if (!tag) { msg.channel.createMessage(errorMessage("No tag with that name")); diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index ee5fc185..348cb90a 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -29,6 +29,7 @@ import { get, getInviteCodesInString, isSnowflake, + messageLink, MINUTES, multiSorter, noop, @@ -56,6 +57,9 @@ import { getCurrentUptime } from "../uptime"; import LCL from "last-commit-log"; import * as t from "io-ts"; import { ICommandDefinition } from "knub-command-manager"; +import path from "path"; +import escapeStringRegexp from "escape-string-regexp"; +import safeRegex from "safe-regex"; const ConfigSchema = t.type({ can_roles: t.boolean, @@ -71,16 +75,20 @@ const ConfigSchema = t.type({ can_vcmove: t.boolean, can_help: t.boolean, can_about: t.boolean, + can_context: t.boolean, }); type TConfigSchema = t.TypeOf; const { performance } = require("perf_hooks"); const SEARCH_RESULTS_PER_PAGE = 15; +const SEARCH_ID_RESULTS_PER_PAGE = 50; + const MAX_CLEAN_COUNT = 150; const MAX_CLEAN_TIME = 1 * DAYS; const CLEAN_COMMAND_DELETE_DELAY = 5000; const MEMBER_REFRESH_FREQUENCY = 10 * 60 * 1000; // How often to do a full member refresh when using !search or !roles --counts +const SEARCH_EXPORT_LIMIT = 1_000_000; const activeReloads: Map = new Map(); @@ -88,10 +96,14 @@ type MemberSearchParams = { query?: string; role?: string; voice?: boolean; + bot?: boolean; sort?: string; "case-sensitive"?: boolean; + regex?: boolean; }; +class SearchError extends Error {} + export class UtilityPlugin extends ZeppelinPlugin { public static pluginName = "utility"; public static configSchema = ConfigSchema; @@ -124,6 +136,7 @@ export class UtilityPlugin extends ZeppelinPlugin { can_vcmove: false, can_help: false, can_about: false, + can_context: false, }, overrides: [ { @@ -138,6 +151,7 @@ export class UtilityPlugin extends ZeppelinPlugin { can_nickname: true, can_vcmove: true, can_help: true, + can_context: true, }, }, { @@ -177,7 +191,7 @@ export class UtilityPlugin extends ZeppelinPlugin { options: [ { name: "counts", - type: "bool", + isSwitch: true, }, { name: "sort", @@ -320,18 +334,27 @@ export class UtilityPlugin extends ZeppelinPlugin { matchingMembers = matchingMembers.filter(m => m.voiceState.channelID != null); } + if (args.bot) { + matchingMembers = matchingMembers.filter(m => m.bot); + } + if (args.query) { - const query = args["case-sensitive"] ? args.query.trimStart() : args.query.toLowerCase().trimStart(); + let queryRegex: RegExp; + if (args.regex) { + queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i"); + } else { + queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); + } + + if (!safeRegex(queryRegex)) { + throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)"); + } matchingMembers = matchingMembers.filter(member => { - const nick = args["case-sensitive"] ? member.nick : member.nick && member.nick.toLowerCase(); + if (member.nick && member.nick.match(queryRegex)) return true; - const fullUsername = args["case-sensitive"] - ? `${member.user.username}#${member.user.discriminator}` - : `${member.user.username}#${member.user.discriminator}`.toLowerCase(); - - if (nick && nick.indexOf(query) !== -1) return true; - if (fullUsername.indexOf(query) !== -1) return true; + const fullUsername = `${member.user.username}#${member.user.discriminator}`; + if (fullUsername.match(queryRegex)) return true; return false; }); @@ -344,15 +367,18 @@ export class UtilityPlugin extends ZeppelinPlugin { matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir)); } else { matchingMembers.sort( - multiSorter([[m => m.username.toLowerCase(), realSortDir], [m => m.discriminator, realSortDir]]), + multiSorter([ + [m => m.username.toLowerCase(), realSortDir], + [m => m.discriminator, realSortDir], + ]), ); } - const lastPage = Math.ceil(matchingMembers.length / SEARCH_RESULTS_PER_PAGE); + const lastPage = Math.max(1, Math.ceil(matchingMembers.length / perPage)); page = Math.min(lastPage, Math.max(1, page)); - const from = (page - 1) * SEARCH_RESULTS_PER_PAGE; - const to = Math.min(from + SEARCH_RESULTS_PER_PAGE, matchingMembers.length); + const from = (page - 1) * perPage; + const to = Math.min(from + perPage, matchingMembers.length); const pageMembers = matchingMembers.slice(from, to); @@ -371,15 +397,23 @@ export class UtilityPlugin extends ZeppelinPlugin { options: [ { name: "page", + shortcut: "p", type: "number", }, { name: "role", + shortcut: "r", type: "string", }, { name: "voice", - type: "bool", + shortcut: "v", + isSwitch: true, + }, + { + name: "bot", + shortcut: "b", + isSwitch: true, }, { name: "sort", @@ -395,6 +429,15 @@ export class UtilityPlugin extends ZeppelinPlugin { shortcut: "e", isSwitch: true, }, + { + name: "ids", + isSwitch: true, + }, + { + name: "regex", + shortcut: "re", + isSwitch: true, + }, ], extra: { info: { @@ -417,15 +460,18 @@ export class UtilityPlugin extends ZeppelinPlugin { msg: Message, args: { query?: string; - role?: string; page?: number; + role?: string; voice?: boolean; + bot?: boolean; sort?: string; "case-sensitive"?: boolean; export?: boolean; + ids?: boolean; + regex?: boolean; }, ) { - const formatSearchResultLines = (members: Member[]) => { + const formatSearchResultList = (members: Member[]): string => { const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0); const lines = members.map(member => { const paddedId = member.id.padEnd(longestId, " "); @@ -433,23 +479,38 @@ export class UtilityPlugin extends ZeppelinPlugin { if (member.nick) line += ` (${member.nick})`; return line; }); - return lines; + return lines.join("\n"); + }; + + const formatSearchResultIdList = (members: Member[]): string => { + return members.map(m => m.id).join(" "); }; // If we're exporting the results, we don't need all the fancy schmancy pagination stuff. // Just get the results and dump them in an archive. if (args.export) { - const results = await this.performMemberSearch(args, 1, Infinity); + let results; + try { + results = await this.performMemberSearch(args, 1, SEARCH_EXPORT_LIMIT); + } catch (e) { + if (e instanceof SearchError) { + return this.sendErrorMessage(msg.channel, e.message); + } + + throw e; + } + if (results.totalResults === 0) { return this.sendErrorMessage(msg.channel, "No results found"); } - const resultLines = formatSearchResultLines(results.results); + const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results); + const archiveId = await this.archives.create( trimLines(` Search results (total ${results.totalResults}): - ${resultLines.join("\n")} + ${resultList} `), moment().add(1, "hour"), ); @@ -468,6 +529,8 @@ export class UtilityPlugin extends ZeppelinPlugin { let clearReactionsFn = null; let clearReactionsTimeout = null; + const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE; + const loadSearchPage = async page => { if (searching) return; searching = true; @@ -482,23 +545,37 @@ export class UtilityPlugin extends ZeppelinPlugin { searchMsgPromise.then(m => (originalSearchMsg = m)); } - const searchResult = await this.performMemberSearch(args, page, SEARCH_RESULTS_PER_PAGE); + let searchResult; + try { + searchResult = await this.performMemberSearch(args, page, perPage); + } catch (e) { + if (e instanceof SearchError) { + return this.sendErrorMessage(msg.channel, e.message); + } + + throw e; + } + if (searchResult.totalResults === 0) { return this.sendErrorMessage(msg.channel, "No results found"); } const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members"; const headerText = - searchResult.totalResults > SEARCH_RESULTS_PER_PAGE + searchResult.totalResults > perPage ? trimLines(` **Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults}) `) : `Found ${searchResult.totalResults} ${resultWord}`; - const lines = formatSearchResultLines(searchResult.results); + + const resultList = args.ids + ? formatSearchResultIdList(searchResult.results) + : formatSearchResultList(searchResult.results); + const result = trimLines(` ${headerText} \`\`\`js - ${lines.join("\n")} + ${resultList} \`\`\` `); @@ -506,7 +583,7 @@ export class UtilityPlugin extends ZeppelinPlugin { searchMsg.edit(result); // Set up pagination reactions if needed. The reactions are cleared after a timeout. - if (searchResult.totalResults > SEARCH_RESULTS_PER_PAGE) { + if (searchResult.totalResults > perPage) { if (!hasReactions) { hasReactions = true; searchMsg.addReaction("⬅"); @@ -514,6 +591,7 @@ export class UtilityPlugin extends ZeppelinPlugin { searchMsg.addReaction("🔄"); const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => { + if (rMsg.id !== searchMsg.id) return; if (userId !== msg.author.id) return; if (!["⬅", "➡", "🔄"].includes(emoji.name)) return; @@ -571,6 +649,8 @@ export class UtilityPlugin extends ZeppelinPlugin { count: savedMessages.length, archiveUrl, }); + + return { archiveUrl }; } @d.command("clean", "", { @@ -683,17 +763,19 @@ export class UtilityPlugin extends ZeppelinPlugin { let responseMsg: Message; if (messagesToClean.length > 0) { - await this.cleanMessages(targetChannel, messagesToClean, msg.author); + const cleanResult = await this.cleanMessages(targetChannel, messagesToClean, msg.author); let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`; - if (targetChannel.id !== msg.channel.id) responseText += ` in <#${targetChannel.id}>`; + if (targetChannel.id !== msg.channel.id) { + responseText += ` in <#${targetChannel.id}>\n${cleanResult.archiveUrl}`; + } responseMsg = await msg.channel.createMessage(successMessage(responseText)); } else { responseMsg = await msg.channel.createMessage(errorMessage(`Found no messages to clean!`)); } - if (targetChannel.id !== msg.channel.id) { + if (targetChannel.id === msg.channel.id) { // Delete the !clean command and the bot response if a different channel wasn't specified // (so as not to spam the cleaned channel with the command itself) setTimeout(() => { @@ -710,9 +792,16 @@ export class UtilityPlugin extends ZeppelinPlugin { basicUsage: "!info 106391128718245888", }, }, + options: [ + { + name: "compact", + shortcut: "c", + isSwitch: true, + }, + ], }) @d.permission("can_info") - async infoCmd(msg: Message, args: { user?: User | UnknownUser }) { + async infoCmd(msg: Message, args: { user?: User | UnknownUser; compact?: boolean }) { const user = args.user || msg.author; let member; @@ -734,15 +823,40 @@ export class UtilityPlugin extends ZeppelinPlugin { embed.title = `${user.username}#${user.discriminator}`; embed.thumbnail = { url: user.avatarURL }; - embed.fields.push({ - name: "User information", - value: - trimLines(` - ID: **${user.id}** - Profile: <@!${user.id}> - Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** - `) + embedPadding, - }); + if (args.compact) { + embed.fields.push({ + name: "User information", + value: trimLines(` + Profile: <@!${user.id}> + Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** + `), + }); + if (member) { + const joinedAt = moment(member.joinedAt); + const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, { + largest: 2, + round: true, + }); + embed.fields[0].value += `\nJoined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})**`; + } else { + embed.fields.push({ + name: "!! USER IS NOT ON THE SERVER !!", + value: embedPadding, + }); + } + msg.channel.createMessage({ embed }); + return; + } else { + embed.fields.push({ + name: "User information", + value: + trimLines(` + ID: **${user.id}** + Profile: <@!${user.id}> + Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** + `) + embedPadding, + }); + } } else { embed.title = `Unknown user`; } @@ -782,7 +896,6 @@ export class UtilityPlugin extends ZeppelinPlugin { value: embedPadding, }); } - const cases = (await this.cases.getByUserId(user.id)).filter(c => !c.is_hidden); if (cases.length > 0) { @@ -994,7 +1107,12 @@ export class UtilityPlugin extends ZeppelinPlugin { ); // Clean up test messages - this.bot.deleteMessages(messages[0].channel.id, messages.map(m => m.id)).catch(noop); + this.bot + .deleteMessages( + messages[0].channel.id, + messages.map(m => m.id), + ) + .catch(noop); } @d.command("source", "", { @@ -1021,6 +1139,30 @@ export class UtilityPlugin extends ZeppelinPlugin { msg.channel.createMessage(`Message source: ${url}`); } + @d.command("context", " ", { + extra: { + info: { + description: "Get a link to the context of the specified message", + basicUsage: "!context 94882524378968064 650391267720822785", + }, + }, + }) + @d.permission("can_context") + async contextCmd(msg: Message, args: { channel: Channel; messageId: string }) { + if (!(args.channel instanceof TextChannel)) { + this.sendErrorMessage(msg.channel, "Channel must be a text channel"); + return; + } + + const previousMessage = (await this.bot.getMessages(args.channel.id, 1, args.messageId))[0]; + if (!previousMessage) { + this.sendErrorMessage(msg.channel, "Message context not found"); + return; + } + + msg.channel.createMessage(messageLink(this.guildId, previousMessage.channel.id, previousMessage.id)); + } + @d.command("vcmove", " ", { extra: { info: { @@ -1192,8 +1334,25 @@ export class UtilityPlugin extends ZeppelinPlugin { const uptime = getCurrentUptime(); const prettyUptime = humanizeDuration(uptime, { largest: 2, round: true }); - const lcl = new LCL(); - const lastCommit = await lcl.getLastCommit(); + let lastCommit; + + try { + // From project root + // FIXME: Store these paths properly somewhere + const lcl = new LCL(path.resolve(__dirname, "..", "..", "..")); + lastCommit = await lcl.getLastCommit(); + } catch (e) {} // tslint:disable-line:no-empty + + let lastUpdate; + let version; + + if (lastCommit) { + lastUpdate = moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]"); + version = lastCommit.shortHash; + } else { + lastUpdate = "?"; + version = "?"; + } const shard = this.bot.shards.get(this.bot.guildShardMap[this.guildId]); @@ -1205,8 +1364,8 @@ export class UtilityPlugin extends ZeppelinPlugin { const basicInfoRows = [ ["Uptime", prettyUptime], ["Last reload", `${lastReload} ago`], - ["Last update", moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]")], - ["Version", lastCommit.shortHash], + ["Last update", lastUpdate], + ["Version", version], ["API latency", `${shard.latency}ms`], ]; diff --git a/backend/src/plugins/ZeppelinPlugin.ts b/backend/src/plugins/ZeppelinPlugin.ts index 3bf8e340..02807738 100644 --- a/backend/src/plugins/ZeppelinPlugin.ts +++ b/backend/src/plugins/ZeppelinPlugin.ts @@ -12,14 +12,16 @@ import { resolveMember, resolveUser, resolveUserId, + tDeepPartial, trimEmptyStartEndLines, trimIndents, UnknownUser, + resolveRoleId, } from "../utils"; import { Invite, Member, User } from "eris"; import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line import { performance } from "perf_hooks"; -import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils"; +import { decodeAndValidateStrict, StrictValidationError, validate } from "../validatorUtils"; import { SimpleCache } from "../SimpleCache"; const SLOW_RESOLVE_THRESHOLD = 1500; @@ -52,8 +54,8 @@ export interface CommandInfo { export function trimPluginDescription(str) { const emptyLinesTrimmed = trimEmptyStartEndLines(str); const lines = emptyLinesTrimmed.split("\n"); - const lastLineIndentation = (lines[lines.length - 1].match(/^ +/g) || [""])[0].length; - return trimIndents(emptyLinesTrimmed, lastLineIndentation); + const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length; + return trimIndents(emptyLinesTrimmed, firstLineIndentation); } const inviteCache = new SimpleCache>(10 * MINUTES, 200); @@ -69,14 +71,14 @@ export class ZeppelinPlugin extends Plug throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId); } - protected canActOn(member1, member2) { - if (member1.id === member2.id || member2.id === this.bot.user.id) { + protected canActOn(member1: Member, member2: Member, allowSameLevel = false) { + if (member2.id === this.bot.user.id) { return false; } const ourLevel = this.getMemberLevel(member1); const memberLevel = this.getMemberLevel(member2); - return ourLevel > memberLevel; + return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel; } /** @@ -121,6 +123,13 @@ export class ZeppelinPlugin extends Plug ? options.overrides : (defaultOptions.overrides || []).concat(options.overrides || []); + // Before preprocessing the static config, do a loose check by checking the schema as deeply partial. + // This way the preprocessing function can trust that if a property exists, its value will be the correct (partial) type. + const initialLooseCheck = this.configSchema ? validate(tDeepPartial(this.configSchema), mergedConfig) : null; + if (initialLooseCheck) { + throw initialLooseCheck; + } + mergedConfig = this.preprocessStaticConfig(mergedConfig); const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; @@ -229,6 +238,16 @@ export class ZeppelinPlugin extends Plug return user; } + /** + * Resolves a role from the passed string. The passed string can be a role ID, a role mention or a role name. + * In the event of duplicate role names, this function will return the first one it comes across. + * @param roleResolvable + */ + async resolveRoleId(roleResolvable: string): Promise { + const roleId = await resolveRoleId(this.bot, this.guildId, roleResolvable); + return roleId; + } + /** * Resolves a member from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. * If the member is not found in the cache, it's fetched from the API. diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index f202ea76..f30034ff 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -27,6 +27,7 @@ import { LocatePlugin } from "./LocateUser"; import { GuildConfigReloader } from "./GuildConfigReloader"; import { ChannelArchiverPlugin } from "./ChannelArchiver"; import { AutomodPlugin } from "./Automod"; +import { RolesPlugin } from "./Roles"; /** * Plugins available to be loaded for individual guilds @@ -58,6 +59,7 @@ export const availablePlugins = [ CompanionChannelPlugin, LocatePlugin, ChannelArchiverPlugin, + RolesPlugin, ]; /** diff --git a/backend/src/utils.test.ts b/backend/src/utils.test.ts new file mode 100644 index 00000000..345c32a3 --- /dev/null +++ b/backend/src/utils.test.ts @@ -0,0 +1,47 @@ +import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString } from "./utils"; + +import test from "ava"; + +test("getUrlsInString(): detects full links", t => { + const urls = getUrlsInString("foo https://google.com/ bar"); + t.is(urls.length, 1); + t.is(urls[0].hostname, "google.com"); +}); + +test("getUrlsInString(): detects partial links", t => { + const urls = getUrlsInString("foo google.com bar"); + t.is(urls.length, 1); + t.is(urls[0].hostname, "google.com"); +}); + +test("getUrlsInString(): detects subdomains", t => { + const urls = getUrlsInString("foo photos.google.com bar"); + t.is(urls.length, 1); + t.is(urls[0].hostname, "photos.google.com"); +}); + +test("delay strings: basic support", t => { + const delayString = "2w4d7h32m17s"; + const expected = 1_582_337_000; + t.is(convertDelayStringToMS(delayString), expected); +}); + +test("delay strings: default unit (minutes)", t => { + t.is(convertDelayStringToMS("10"), 10 * 60 * 1000); +}); + +test("delay strings: custom default unit", t => { + t.is(convertDelayStringToMS("10", "s"), 10 * 1000); +}); + +test("delay strings: reverse conversion", t => { + const ms = 1_582_337_020; + const expected = "2w4d7h32m17s20x"; + t.is(convertMSToDelayString(ms), expected); +}); + +test("delay strings: reverse conversion (conservative)", t => { + const ms = 1_209_600_000; + const expected = "2w"; + t.is(convertMSToDelayString(ms), expected); +}); diff --git a/backend/src/utils.ts b/backend/src/utils.ts index dee6b8d9..1cb5af74 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -9,6 +9,7 @@ import { GuildAuditLogEntry, GuildChannel, Member, + Message, MessageContent, TextableChannel, TextChannel, @@ -27,6 +28,10 @@ import https from "https"; import tmp from "tmp"; import { logger, waitForReaction } from "knub"; import { SavedMessage } from "./data/entities/SavedMessage"; +import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils"; +import { either } from "fp-ts/lib/Either"; +import safeRegex from "safe-regex"; +import moment from "moment-timezone"; const delayStringMultipliers = { w: 1000 * 60 * 60 * 24 * 7, @@ -34,6 +39,7 @@ const delayStringMultipliers = { h: 1000 * 60 * 60, m: 1000 * 60, s: 1000, + x: 1, }; export const MS = 1; @@ -41,11 +47,141 @@ export const SECONDS = 1000 * MS; export const MINUTES = 60 * SECONDS; export const HOURS = 60 * MINUTES; export const DAYS = 24 * HOURS; +export const WEEKS = 7 * 24 * HOURS; -export function tNullable>(type: T) { +export const EMPTY_CHAR = "\u200b"; + +export function tNullable>(type: T) { return t.union([type, t.undefined, t.null], `Nullable<${type.name}>`); } +function typeHasProps(type: any): type is t.TypeC { + return type.props != null; +} + +function typeIsArray(type: any): type is t.ArrayC { + return type._tag === "ArrayType"; +} + +export type TDeepPartial = T extends t.InterfaceType + ? TDeepPartialProps + : T extends t.DictionaryType + ? t.DictionaryType> + : T extends t.UnionType + ? t.UnionType>> + : T extends t.IntersectionType + ? t.IntersectionType>> + : T extends t.ArrayType + ? t.ArrayType> + : T; + +// Based on t.PartialC +export interface TDeepPartialProps

+ extends t.PartialType< + P, + { + [K in keyof P]?: TDeepPartial>; + }, + { + [K in keyof P]?: TDeepPartial>; + } + > {} + +export function tDeepPartial(type: T): TDeepPartial { + if (type instanceof t.InterfaceType) { + const newProps = {}; + for (const [key, prop] of Object.entries(type.props)) { + newProps[key] = tDeepPartial(prop); + } + return t.partial(newProps) as TDeepPartial; + } else if (type instanceof t.DictionaryType) { + return t.record(type.domain, tDeepPartial(type.codomain)) as TDeepPartial; + } else if (type instanceof t.UnionType) { + return t.union(type.types.map(unionType => tDeepPartial(unionType))) as TDeepPartial; + } else if (type instanceof t.IntersectionType) { + const types = type.types.map(intersectionType => tDeepPartial(intersectionType)); + return (t.intersection(types as [t.Mixed, t.Mixed]) as unknown) as TDeepPartial; + } else if (type instanceof t.ArrayType) { + return t.array(tDeepPartial(type.type)) as TDeepPartial; + } else { + return type as TDeepPartial; + } +} + +function tDeepPartialProp(prop: any) { + if (typeHasProps(prop)) { + return tDeepPartial(prop); + } else if (typeIsArray(prop)) { + return t.array(tDeepPartialProp(prop.type)); + } else { + return prop; + } +} + +/** + * Mirrors EmbedOptions from Eris + */ +export const tEmbed = t.type({ + title: tNullable(t.string), + description: tNullable(t.string), + url: tNullable(t.string), + timestamp: tNullable(t.string), + color: tNullable(t.number), + footer: tNullable( + t.type({ + text: t.string, + icon_url: tNullable(t.string), + proxy_icon_url: tNullable(t.string), + }), + ), + image: tNullable( + t.type({ + url: tNullable(t.string), + proxy_url: tNullable(t.string), + width: tNullable(t.number), + height: tNullable(t.number), + }), + ), + thumbnail: tNullable( + t.type({ + url: tNullable(t.string), + proxy_url: tNullable(t.string), + width: tNullable(t.number), + height: tNullable(t.number), + }), + ), + video: tNullable( + t.type({ + url: tNullable(t.string), + width: tNullable(t.number), + height: tNullable(t.number), + }), + ), + provider: tNullable( + t.type({ + name: t.string, + url: tNullable(t.string), + }), + ), + fields: tNullable( + t.array( + t.type({ + name: tNullable(t.string), + value: tNullable(t.string), + inline: tNullable(t.boolean), + }), + ), + ), + author: tNullable( + t.type({ + name: t.string, + url: tNullable(t.string), + width: tNullable(t.number), + height: tNullable(t.number), + }), + ), +}); + export function dropPropertiesByName(obj, propName) { if (obj.hasOwnProperty(propName)) delete obj[propName]; for (const value of Object.values(obj)) { @@ -55,6 +191,40 @@ export function dropPropertiesByName(obj, propName) { } } +export const tAlphanumeric = new t.Type( + "tAlphanumeric", + (s): s is string => typeof s === "string", + (from, to) => + either.chain(t.string.validate(from, to), s => { + return s.match(/\W/) ? t.failure(from, to, "String must be alphanumeric") : t.success(s); + }), + s => s, +); + +export const tDateTime = new t.Type( + "tDateTime", + (s): s is string => typeof s === "string", + (from, to) => + either.chain(t.string.validate(from, to), s => { + const parsed = + s.length === 10 ? moment(s, "YYYY-MM-DD") : s.length === 19 ? moment(s, "YYYY-MM-DD HH:mm:ss") : null; + + return parsed && parsed.isValid() ? t.success(s) : t.failure(from, to, "Invalid datetime"); + }), + s => s, +); + +export const tDelayString = new t.Type( + "tDelayString", + (s): s is string => typeof s === "string", + (from, to) => + either.chain(t.string.validate(from, to), s => { + const ms = convertDelayStringToMS(s); + return ms === null ? t.failure(from, to, "Invalid delay string") : t.success(s); + }), + s => s, +); + /** * Turns a "delay string" such as "1h30m" to milliseconds */ @@ -79,8 +249,23 @@ export function convertDelayStringToMS(str, defaultUnit = "m"): number { return ms; } +export function convertMSToDelayString(ms: number): string { + let result = ""; + let remaining = ms; + for (const [abbr, multiplier] of Object.entries(delayStringMultipliers)) { + if (multiplier <= remaining) { + const amount = Math.floor(remaining / multiplier); + result += `${amount}${abbr}`; + remaining -= amount * multiplier; + } + + if (remaining === 0) break; + } + return result; +} + export function successMessage(str) { - return `👌 ${str}`; + return `<:zep_check:650361014180904971> ${str}`; } export function errorMessage(str) { @@ -189,7 +374,7 @@ const urlRegex = /(\S+\.\S+)/g; const protocolRegex = /^[a-z]+:\/\//; export function getUrlsInString(str: string, unique = false): url.URL[] { - let matches = (str.match(urlRegex) || []).map(m => m[0]); + let matches = str.match(urlRegex) || []; if (unique) matches = Array.from(new Set(matches)); return matches.reduce((urls, match) => { @@ -216,15 +401,7 @@ export function getUrlsInString(str: string, unique = false): url.URL[] { export function getInviteCodesInString(str: string): string[] { const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi; - const inviteCodes = []; - let match; - - // tslint:disable-next-line - while ((match = inviteCodeRegex.exec(str)) !== null) { - inviteCodes.push(match[1]); - } - - return inviteCodes; + return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]); } export const unicodeEmojiRegex = emojiRegex(); @@ -333,7 +510,7 @@ export function getRoleMentions(str: string) { } /** - * Disables link previews in the given string by wrapping links in < > + * Disable link previews in the given string by wrapping links in < > */ export function disableLinkPreviews(str: string): string { return str.replace(/(?"); @@ -343,6 +520,17 @@ export function deactivateMentions(content: string): string { return content.replace(/@/g, "@\u200b"); } +/** + * Disable inline code in the given string by replacing backticks/grave accents with acute accents + * FIXME: Find a better way that keeps the grave accents? Can't use the code block approach here since it's just 1 character. + */ +export function disableInlineCode(content: string): string { + return content.replace(/`/g, "\u00b4"); +} + +/** + * Disable code blocks in the given string by adding invisible unicode characters between backticks + */ export function disableCodeBlocks(content: string): string { return content.replace(/`/g, "`\u200b"); } @@ -768,6 +956,32 @@ export async function resolveMember(bot: Client, guild: Guild, value: string): P return null; } +export async function resolveRoleId(bot: Client, guildId: string, value: string) { + if (value == null) { + return null; + } + + // Role mention + const mentionMatch = value.match(/^<@&?(\d+)>$/); + if (mentionMatch) { + return mentionMatch[1]; + } + + // Role name + const roleList = await bot.getRESTGuildRoles(guildId); + const role = roleList.filter(x => x.name.toLocaleLowerCase() === value.toLocaleLowerCase()); + if (role[0]) { + return role[0].id; + } + + // Role ID + const idMatch = value.match(/^\d+$/); + if (idMatch) { + return value; + } + return null; +} + export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions }; export async function confirm(bot: Client, channel: TextableChannel, userId: string, content: MessageContent) { @@ -805,3 +1019,28 @@ export function verboseUserName(user: User | UnknownUser): string { export function verboseChannelMention(channel: GuildChannel): string { return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`; } + +export function messageLink(message: Message): string; +export function messageLink(guildId: string, channelId: string, messageId: string): string; +export function messageLink(guildIdOrMessage: string | Message | null, channelId?: string, messageId?: string): string { + let guildId; + if (guildIdOrMessage == null) { + // Full arguments without a guild id -> DM/Group chat + guildId = "@me"; + } else if (guildIdOrMessage instanceof Message) { + // Message object as the only argument + guildId = (guildIdOrMessage.channel as GuildChannel).guild?.id ?? "@me"; + channelId = guildIdOrMessage.channel.id; + messageId = guildIdOrMessage.id; + } else { + // Full arguments with all IDs + guildId = guildIdOrMessage; + } + + return `https://discordapp.com/channels/${guildId}/${channelId}/${messageId}`; +} + +export function isValidEmbed(embed: any): boolean { + const result = decodeAndValidateStrict(tEmbed, embed); + return !(result instanceof StrictValidationError); +} diff --git a/backend/src/validation.test.ts b/backend/src/validation.test.ts new file mode 100644 index 00000000..a916fff9 --- /dev/null +++ b/backend/src/validation.test.ts @@ -0,0 +1,41 @@ +import { tDeepPartial } from "./utils"; +import * as t from "io-ts"; +import * as validatorUtils from "./validatorUtils"; +import test from "ava"; +import util from "util"; + +test("tDeepPartial works", ava => { + const originalSchema = t.type({ + listOfThings: t.record( + t.string, + t.type({ + enabled: t.boolean, + someValue: t.number, + }), + ), + }); + + const deepPartialSchema = tDeepPartial(originalSchema); + + const partialValidValue = { + listOfThings: { + myThing: { + someValue: 5, + }, + }, + }; + + const partialErrorValue = { + listOfThings: { + myThing: { + someValue: "test", + }, + }, + }; + + const result1 = validatorUtils.validate(deepPartialSchema, partialValidValue); + ava.is(result1, null); + + const result2 = validatorUtils.validate(deepPartialSchema, partialErrorValue); + ava.not(result2, null); +}); diff --git a/backend/src/validatorUtils.ts b/backend/src/validatorUtils.ts index a200566e..b5743030 100644 --- a/backend/src/validatorUtils.ts +++ b/backend/src/validatorUtils.ts @@ -57,7 +57,7 @@ function getContextPath(context) { // tslint:enable export class StrictValidationError extends Error { - private errors; + private readonly errors; constructor(errors: string[]) { errors = Array.from(new Set(errors)); @@ -83,6 +83,17 @@ const report = fold((errors: any): StrictValidationError | void => { return new StrictValidationError(errorStrings); }, noop); +export function validate(schema: t.Type, value: any): StrictValidationError | null { + const validationResult = schema.decode(value); + return pipe( + validationResult, + fold( + err => report(validationResult), + result => null, + ), + ); +} + /** * Decodes and validates the given value against the given schema while also disallowing extra properties * See: https://github.com/gcanti/io-ts/issues/322 diff --git a/backend/start-dev.js b/backend/start-dev.js new file mode 100644 index 00000000..5b6c3b44 --- /dev/null +++ b/backend/start-dev.js @@ -0,0 +1,18 @@ +/** + * This file starts the bot and api processes in tandem. + * Used with tsc-watch for restarting on watch. + */ + +const childProcess = require("child_process"); + +const cmd = process.platform === "win32" + ? "npm.cmd" + : "npm"; + +childProcess.spawn(cmd, ["run", "start-bot-dev"], { + stdio: [process.stdin, process.stdout, process.stderr], +}); + +childProcess.spawn(cmd, ["run", "start-api-dev"], { + stdio: [process.stdin, process.stdout, process.stderr], +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 2b555b99..84c78116 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,9 +6,8 @@ "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, - "target": "esnext", + "target": "es2018", "lib": [ - "es2017", "esnext" ], "baseUrl": ".", @@ -19,7 +18,8 @@ "@shared/*": [ "../shared/src/*" ] - } + }, + "sourceMap": true }, "include": [ "src/**/*.ts" diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index bc0389ac..42745a2a 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "moduleResolution": "node", "module": "esnext", - "target": "esnext", + "target": "es2018", "sourceMap": true, "noImplicitAny": false, "allowSyntheticDefaultImports": true, diff --git a/package-lock.json b/package-lock.json index f240539a..936e5bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1218,9 +1218,9 @@ } }, "prettier": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz", - "integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", "dev": true }, "pump": { diff --git a/package.json b/package.json index 7a683076..28962d99 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,13 @@ "description": "", "private": true, "scripts": { - "test": "cd backend && npm run test && cd ../shared && npm run test" + "format": "prettier --write \"./{backend,dashboard}/**/*.ts\"" }, "dependencies": {}, "devDependencies": { "husky": "^3.0.9", "lint-staged": "^9.4.2", - "prettier": "^1.16.4", + "prettier": "^1.19.1", "tslint": "^5.13.1", "tslint-config-prettier": "^1.18.0", "typescript": "^3.7.2" diff --git a/shared/tsconfig.json b/shared/tsconfig.json index d4ca702c..a9c70fd7 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -6,7 +6,7 @@ "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, - "target": "esnext", + "target": "es2018", "lib": [ "esnext" ],