commit c1db61fecd7294981f68d3d8126218354d8cd383 Author: aididan20 <45387741+aididan20@users.noreply.github.com> Date: Sat Feb 13 21:52:23 2021 +0100 Upload diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..096cb82 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.env +node_modules +npm-debug.logs +config/local* \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..1f99f24 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,9 @@ +node_modules/* +test/* +build/* +public/* +private/* +views/* +*.md +*.json +commands/Admin/Eval.js \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..1bc7777 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,108 @@ + +{ + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 2017 + }, + "env": { + "node": true + }, + "globals": { + "Promise": true + }, + "rules": { + "accessor-pairs": "warn", + "array-callback-return": "error", + // "complexity": "warn", + "dot-location": ["error", "property"], + "dot-notation": "error", + "eqeqeq": "error", + "no-empty-function": "error", + "no-floating-decimal": "error", + "no-implied-eval": "error", + "no-invalid-this": "error", + "no-lone-blocks": "error", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-new": "error", + "no-octal-escape": "error", + "no-return-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-throw-literal": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-useless-call": "error", + "no-useless-escape": "error", + "no-void": "error", + "no-warning-comments": "warn", + "wrap-iife": "error", + "yoda": "error", + + "no-label-var": "error", + "no-undef-init": "error", + + "callback-return": "error", + "handle-callback-err": "error", + "no-mixed-requires": "error", + "no-new-require": "error", + "no-path-concat": "error", + + "array-bracket-spacing": "error", + "block-spacing": "error", + "brace-style": ["error", "1tbs", { "allowSingleLine": true }], + "comma-dangle": ["error", "always-multiline"], + "comma-spacing": "error", + "comma-style": "error", + "computed-property-spacing": "error", + "consistent-this": ["error", "$this"], + "eol-last": "error", + "func-names": "error", + "func-style": ["error", "declaration", { "allowArrowFunctions": true }], + "keyword-spacing": "error", + "max-depth": "error", + // "max-len": ["error", 120, 2], + "max-nested-callbacks": ["error", { "max": 4 }], + "max-statements-per-line": ["error", { "max": 2 }], + "new-cap": "error", + "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 3 }], + "no-array-constructor": "error", + // "no-inline-comments": "error", + "no-lonely-if": "error", + "no-mixed-operators": "error", + "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], + "no-new-object": "error", + "no-spaced-func": "error", + "no-trailing-spaces": "error", + "no-unneeded-ternary": "error", + "no-whitespace-before-property": "error", + "object-curly-spacing": ["error", "always"], + "operator-assignment": "error", + "operator-linebreak": ["error", "after"], + "padded-blocks": ["error", "never"], + "quote-props": ["error", "as-needed"], + "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], + "semi-spacing": "error", + "semi": "error", + "space-before-blocks": "error", + "space-before-function-paren": ["error", "never"], + "space-in-parens": "error", + "space-infix-ops": "error", + "space-unary-ops": "error", + "spaced-comment": "error", + "unicode-bom": "error", + + "arrow-body-style": "error", + "arrow-spacing": "error", + "no-duplicate-imports": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "prefer-arrow-callback": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + // "prefer-template": "error", + "rest-spread-spacing": "error", + "template-curly-spacing": "error", + "yield-star-spacing": "error" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bd7f2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +node_modules + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Ignore local database files +*.db + +.env +private +.idea +.vscode +.DS_Store +jsconfig.json +pm2.json +Apollo.js +build +package-lock.json +config/local* +src/core/crashreport* +report.* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1516a0a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "translations"] + path = translations + url = git@github.com:FlexLabs/dyno-translations.git diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8097a29 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:carbon-alpine + +WORKDIR /app +ARG NPM_TOKEN + +COPY package*.json ./ +COPY .npmrc .npmrc + +RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc +RUN apk add --no-cache --update python build-base git \ + && rm -rf /var/cache/apk/* + +RUN yarn cache clean +RUN yarn install --production + +COPY . . + +RUN rm -f .npmrc +RUN apk del python build-base git + +ENV NODE_ENV production + +CMD [ "yarn", "start" ] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..79a169d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,402 @@ +Attribution-NonCommercial-NoDerivatives 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 +International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-NonCommercial-NoDerivatives 4.0 International Public +License ("Public License"). To the extent this Public License may be +interpreted as a contract, You are granted the Licensed Rights in +consideration of Your acceptance of these terms and conditions, and the +Licensor grants You such rights in consideration of benefits the +Licensor receives from making the Licensed Material available under +these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + c. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + d. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + e. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + f. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + g. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + h. NonCommercial means not primarily intended for or directed towards + commercial advantage or monetary compensation. For purposes of + this Public License, the exchange of the Licensed Material for + other material subject to Copyright and Similar Rights by digital + file-sharing or similar means is NonCommercial provided there is + no payment of monetary compensation in connection with the + exchange. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part, for NonCommercial purposes only; and + + b. produce and reproduce, but not Share, Adapted Material + for NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties, including when + the Licensed Material is used other than for NonCommercial + purposes. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material, You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + For the avoidance of doubt, You do not have permission under + this Public License to Share Adapted Material. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database for NonCommercial purposes + only and provided You do not Share Adapted Material; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1572677 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Original readme removed. \ No newline at end of file diff --git a/config/default.yaml b/config/default.yaml new file mode 100644 index 0000000..0bd90fa --- /dev/null +++ b/config/default.yaml @@ -0,0 +1,123 @@ +--- +name: Dyno +author: NoobLance#0002 +version: 4.0.0 +lib: eris +poweredBy: Dyno + +# prefixes +prefix: '?' +sudopref: '$' +localPrefix: +adminPrefix: d. + +test: true +state: 0 +stateName: Default +logLevel: debug +isPremium: false + +# cluster/sharding +clusterCount: 1 +shardingStrategy: shared +firstShardOverride: 0 +lastShardOverride: 0 +shardCountOverride: 1 +clusterIds: +shardIds: + +# client config +client: + id: '' + secret: '' + token: '' + userid: '' + game: dynobot.net | ?help + admin: '155037590859284481' + fetchAllUsers: false + disableEveryone: false + maxCachedMessages: 10 + + +snowgate: + host: '' + +site: + host: http://localhost + port: 80 + listen_port: 8000 + +mongo: + dsn: mongodb://localhost/discordbot + +redis: + host: localhost + port: 6379 + auth: '' + +sentry: + dsn: '' + logLevel: error + +statsd: + host: '' + port: 4280 + prefix: dyno.dev. + +emojis: + success: '<:dynoSuccess:314691591484866560>' + error: '<:dynoError:314691684455809024>' + +# bot list config +carbon: + key: '' + url: https://www.carbonitex.net/discord/data/botdata.php + list: https://www.carbonitex.net/discord/api/listedbots.php + info: https://www.carbonitex.net/discord/api/bot/info?id=155149108183695360 +dbots: + key: '' + url: https://bots.discord.pw/api/bots/155149108183695360/stats +dbl: + key: '' + url: https://discordbots.org/api/bots/155149108183695360/stats +botspace: + key: '' + url: https://botlist.space/spi/bots/ +cleverbot: + key: '' + +invite: https://discord.gg/9W6EG56 +avatar: https://cdn.dyno.gg/dyno-v3x1024.png + +dynoGuild: '203039963636301824' +guildLog: '205567372021465088' +largeGuildLog: '243639503749775360' +testGuilds: +- '203039963636301824' +- '155149443606380545' + +shardWebhook: https://canary.discordapp.com/api/webhooks/263596728299683850/fygpIRg8pcD9nLPL2MxUjK8mupD6dnLfzA1eIwocoD_MnFzba1noE0sXY4XY_ZNfkPtt +cluster: + webhookUrl: https://canary.discordapp.com/api/webhooks/263596728299683850/fygpIRg8pcD9nLPL2MxUjK8mupD6dnLfzA1eIwocoD_MnFzba1noE0sXY4XY_ZNfkPtt + +disableHeartbeat: true +logCommands: false +handleRegion: false +regions: [] +disableEvents: +- TYPING_START + +enabledCommandGroups: +disabledCommandGroups: + +disableHelp: false +maxStreamLimit: 4000 +maxSongLength: 5400 +maxPlayingTime: 14400000 +streamLimitThreshold: 86400 + +modules: +- AdminHandler +- CommandHandler +- ShardStatus +- Dyno diff --git a/config/development.yaml b/config/development.yaml new file mode 100644 index 0000000..114c8c2 --- /dev/null +++ b/config/development.yaml @@ -0,0 +1,34 @@ +--- +# prefixes +prefix: '?' +sudopref: '$' +localPrefix: '.' +adminPrefix: t. + +env: 'dev' +test: true +state: 2 +stateName: Development +logLevel: debug +isPremium: false + +# sharding +clusterCount: 1 +shardingStrategy: process +shardCountOverride: 1 + +site: + host: http://localhost + port: 80 + listen_port: 8000 + +colorapi: + host: https://color.dyno.gg + +disableHelp: true + +modules: +- AdminHandler +- CommandHandler +- ShardStatus +- Dyno diff --git a/config/production.yaml b/config/production.yaml new file mode 100644 index 0000000..d9dc954 --- /dev/null +++ b/config/production.yaml @@ -0,0 +1,131 @@ +--- +# prefixes +prefix: "?" +sudopref: "$" +localPrefix: +adminPrefix: d. + +env: 'prod' +test: false +state: 3 +stateName: Prod +logLevel: info + +isCore: true +isPremium: false + +# sharding +clusterCount: 1 +shardingStrategy: balanced +firstShardOverride: +lastShardOverride: +shardCountOverride: + +# client +client: + id: '' + secret: '' + token: '' + userid: '' + game: dynobot.net | ?help + admin: '155037590859284481' + fetchAllUsers: false + disableEveryone: false + maxCachedMessages: 10 + lazyChunking: true + +snowgate: + host: '' + +site: + host: http://localhost + port: 80 + listen_port: 8000 + +mongo: + dsn: mongodb://localhost/discordbot + +redis: + host: localhost + port: 6379 + auth: '' + +sentry: +# dsn: https://dedbe1a9f79c4b7384dacf0514235665:3a855fbfa7b24ee0ae4cfa98b5f3da3f@sentry.davinci.sh/8 + logLevel: error + +statsd: + host: '' + port: 4280 + prefix: dyno.dev. + +emojis: + success: "<:dynoSuccess:314691591484866560>" + error: "<:dynoError:314691684455809024>" + +carbon: + key: '' + url: https://www.carbonitex.net/discord/data/botdata.php + list: https://www.carbonitex.net/discord/api/listedbots.php + info: https://www.carbonitex.net/discord/api/bot/info?id=155149108183695360 + +dbots: + key: '' + url: https://bots.discord.pw/api/bots/155149108183695360/stats + +dbl: + key: '' + url: https://discordbots.org/api + +invite: https://discord.gg/9W6EG56 +avatar: http://cdn.dyno.gg/dyno-av-v3x1024.png + +dynoGuild: '203039963636301824' +guildLog: '205567372021465088' +largeGuildLog: '243639503749775360' + +# webhooks +blockWebhook: https://canary.discordapp.com/api/webhooks/543125066133798922/zm5dAE84tmfsAJuh7vP4uW2fGIbhVOU1sBJdr5i-uTojreaEVLTWzTdKjzpddCrj2qWw +shardWebhook: https://canary.discordapp.com/api/webhooks/511859673171886090/vC_L2tARlJIdIEFOnpJQPFTPPZ1gzA8LUoEaRQnXBCFK1sDXHKHPJ2mWF6XaTNwIrbhz +cluster: + webhookUrl: https://canary.discordapp.com/api/webhooks/511859572617773078/HR9nzO1SbIbg6Wdy2TebJU9gngDg-vY_iPqX2L_WjzUkT74HbknKP4qiJN69pluUKG95 + +disableHeartbeat: true +disableHelp: false +logCommands: false +handleRegion: false +disableEvents: +- TYPING_START +maxStreamLimit: 4000 +maxSongLength: 5400 +maxPlayingTime: 14400000 +streamLimitThreshold: 86400 + +modules: +- AdminHandler +- CommandHandler +- Manager +- ShardStatus +- Lavalink +- Dyno +- ActionLog +- Announcements +- Automod +- Automessage +- Autopurge +- Autoroles +- Autoresponder +- CustomCommands +- AFK +- Moderation +- Music +- Reminders +- Tags +- VoiceTextLinking +- Streams +- Fun +- Cleverbot +- CoordsChannel +- ERM +- MessageEmbedder +- Slowmode diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..163df6e --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,37 @@ +'use strict'; + +const gulp = require('gulp'); +const babel = require('gulp-babel'); +const eslint = require('gulp-eslint'); + +const paths = ['src/**/*.js']; + +gulp.task('default', ['build']); + +gulp.task('watch', ['build'], () => { + gulp.watch(paths, ['build']); +}); + +gulp.task('build', ['babel']); + +gulp.task('lint', () => { + gulp.src(paths) + .pipe(eslint()) + .pipe(eslint.format()) + .pipe(eslint.failAfterError()); +}); + +// gulp.task('sass', () => +// gulp.src('./public/css/**/*.scss') +// .pipe(sass({ outputStyle: 'compressed' }).on('error', sass.logError)) +// .pipe(gulp.dest('./public/css'))); + +// gulp.task('sass:watch', () => { +// gulp.watch('./public/css/**/*.scss', ['sass']); +// }); + +gulp.task('babel', () => { + gulp.src(paths) + .pipe(babel()) + .pipe(gulp.dest('build')); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..7c5d5a1 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "name": "Dyno", + "version": "4.0.0", + "homepage": "https://github.com/FlexLabs/Dyno", + "author": { + "name": "Brian Tanner", + "email": "brian@tanner.io" + }, + "repository": { + "type": "git", + "url": "https://github.com/FlexLabs/Dyno.git" + }, + "bugs": { + "url": "https://github.com/FlexLabs/Dyno/issues" + }, + "private": true, + "scripts": { + "start": "node src/start.js" + }, + "main": "src/index.js", + "typings": "src/index.d.ts", + "engines": { + "node": ">=6.0.0" + }, + "engineStrict": true, + "dependencies": { + "@dyno.gg/customcommands": "^1.1.0", + "@dyno.gg/datafactory": "^1.4.3", + "@dyno.gg/dyno-core": "^1.3.0", + "@dyno.gg/eris": "0.8.18", + "async-each": "^1.0.1", + "axios": "^0.17.1", + "blocked": "^1.2.1", + "bluebird": "^3.4.1", + "bufferutil": ">=1.2.1", + "cli-progress": "^3.1.0", + "config": "^1.29.4", + "dot-object": "^1.9.0", + "envkey": "^1.2.7", + "eventemitter3": "^2.0.2", + "express": "^4.16.4", + "glob": "^7.1.2", + "glob-promise": "^3.1.0", + "google": "^2.1.0", + "hot-shots": "^4.3.1", + "ioredis": "^3.2.1", + "ioredis-lock": "^3.4.0", + "jayson": "^3.0.2", + "js-yaml": "^3.10.0", + "matomo-tracker": "^2.2.0", + "minimatch": "^3.0.4", + "moment": "^2.13.0", + "moment-duration-format": "^1.3.0", + "mongoose_schema-json": "^1.0.3", + "node-oom-heapdump": "^1.1.4", + "pidusage": "^1.1.0", + "prom-client": "^11.3.0", + "raven": "^1.1.1", + "snowtransfer": "^0.2.1", + "uuid": "^3.3.2", + "winston": "github:briantanner/winston", + "zlib-sync": "^0.1.5" + }, + "devDependencies": { + "eslint": "^3.5.0", + "gulp": "^3.9.1", + "gulp-eslint": "^3.0.1", + "longjohn": "^0.2.12" + }, + "optionalDependencies": { + "@dyno.gg/automod": "^1.3.5", + "@dyno.gg/autoroles": "^1.1.5", + "@dyno.gg/fun": "0.3.3", + "@dyno.gg/manager": "^1.0.11", + "@dyno.gg/moderation": "^1.3.8", + "@dyno.gg/modules": "^1.5.6", + "@dyno.gg/music": "^4.4.0" + } +} diff --git a/pm2/dyno.bot.json b/pm2/dyno.bot.json new file mode 100644 index 0000000..b3566f6 --- /dev/null +++ b/pm2/dyno.bot.json @@ -0,0 +1,11 @@ +{ + "apps": [ + { + "name": "Dyno", + "script": "src/start.js", + "pmx": false, + "vizion": false, + "env_production": { "NODE_ENV": "production" } + } + ] +} diff --git a/scripts/convertConfig.js b/scripts/convertConfig.js new file mode 100644 index 0000000..8e872a6 --- /dev/null +++ b/scripts/convertConfig.js @@ -0,0 +1,17 @@ +const dot = require('dot-object'); + +const config = { }; + +const flatConfig = dot.dot(config); + +for (let k of Object.keys(flatConfig)) { + switch (typeof flatConfig[k]) { + case 'object': + case 'string': + break; + default: + flatConfig[k] += `$typeof:${typeof flatConfig[k]}`; + } +} + +console.log(flatConfig); diff --git a/scripts/execAll.sh b/scripts/execAll.sh new file mode 100644 index 0000000..3fd5112 --- /dev/null +++ b/scripts/execAll.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +servers=("titan" "atlas" "pandora" "hype" "prom" "janus" "sinope" "narvi" "gany" "europa" "elara" "metis") + +for s in "${servers[@]}" +do + ssh dyno@"$s".dyno.lan $* +done \ No newline at end of file diff --git a/scripts/loadActivity.js b/scripts/loadActivity.js new file mode 100644 index 0000000..a922173 --- /dev/null +++ b/scripts/loadActivity.js @@ -0,0 +1,105 @@ +const progress = require('cli-progress'); +const db = require('../src/core/database.js'); +const config = require('../src/core/config'); +const redis = require('../src/core/redis'); + +const clientId = config.client.id; +const shardCount = 1152; + +const multibar = new progress.MultiBar({ + format: '{name} |{bar}| {percentage}% | {duration_formatted} | {value}/{total}', + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + clearOnComplete: false, + stopOnComplete: true, + hideCursor: true, +}); + +let mongoBar; +let redisBar; + +const buffer = []; + +async function flushToRedis() { + const batchSize = 1000; + const batchCount = Math.ceil(buffer.length / batchSize); + + let barProgress = 0; + redisBar = multibar.create(batchCount, barProgress, { name: 'Buffer -> Redis' }); + + let itemsInPipeline = 0; + let pipeline = redis.pipeline(); + + for (let e of buffer) { + const shardId = ~~((e._id / 4194304) % shardCount); + pipeline.hset(`guild_activity:${clientId}:${shardCount}:${shardId}`, e._id, e.lastActive); + + itemsInPipeline++; + + if (itemsInPipeline >= batchSize) { + await pipeline.exec(); + pipeline = redis.pipeline(); + itemsInPipeline = 0; + barProgress++; + redisBar.update(barProgress); + } + } + if (itemsInPipeline > 0) { + await pipeline.exec(); + } + barProgress++; + redisBar.update(barProgress); + redisBar.stop(); +} + +async function fetchMongoDocs() { + const coll = await db.collection('servers'); + const count = await coll.count({ deleted: false }); + + let i = 0; + mongoBar = multibar.create(count, i, { name: 'Mongo -> Buffer' }); + + coll.find({ deleted: false }, { projection: { lastActive: 1 } }).forEach((doc) => { + i++; + + if (!doc.lastActive) { + return mongoBar.update(i); + } + + buffer.push(doc); + + mongoBar.update(i); + }, + (err) => { + if (err) { + console.error(err); + process.exit(1); + } + mongoBar.stop(); + flushToRedis(); + }); +} + +setTimeout(fetchMongoDocs, 5000); +// models.Server.countDocuments({ deleted: false }).then(count => { +// let i = 0, p = 0; +// mongoBar = multibar.create(count, i, { name: 'Mongo -> Buffer' }); + +// models.Server.find({ deleted: false }, { lastActive: 1 }) +// .cursor() +// .on('data', doc => { +// i++; + +// if (!doc.lastActive) { +// return mongoBar.update(i); +// } + +// buffer.push(doc); + +// mongoBar.update(i); +// }) +// .on('end', () => { +// mongoBar.stop(); +// flushToRedis(); +// }); +// }); diff --git a/scripts/reshard.js b/scripts/reshard.js new file mode 100644 index 0000000..1e1184c --- /dev/null +++ b/scripts/reshard.js @@ -0,0 +1,101 @@ +const { collection, connection, models } = require('../src/core/database'); +const config = require('../src/core/config'); + +async function createClusters(options) { + try { + const { clientId, shardCount } = options; + const globalConfig = await models.Dyno.findOne().lean(); + let { clusterCount, serverMap } = globalConfig; + let firstShardId = 0; + let lastShardId = shardCount - 1; + + clusterCount = options.clusterCount || clusterCount; + + const shardIds = [...Array(1 + lastShardId - firstShardId).keys()].map(v => firstShardId + v); + let clusters = chunkArray(shardIds, clusterCount); + let servers = chunkArray(clusters, serverMap[clientId].length); + + clusters = servers.flatMap((s, i) => { + const server = serverMap[clientId][i]; + return s.map((c, i) => ({ + host: { + name: server.name, + hostname: server.host || `${server.name}.dyno.lan`, + state: server.state, + }, + clientId, + clusterCount, + shardCount, + firstShardId: c[0], + lastShardId: c[c.length-1], + env: options.env || 'dev', + })); + }).map((c, i) => ({ id: i, ...c })); + + const coll = collection('clusters'); + const states = serverMap[clientId].map(s => s.state); + + await coll.deleteMany({ 'host.state': { $in: states } }); + await coll.insertMany(clusters); + + connection.close(); + } catch (err) { + throw err; + } +} + +function chunkArray(arr, chunkCount) { + const arrLength = arr.length; + const tempArray = []; + let chunk = []; + + const chunkSize = Math.floor(arr.length / chunkCount); + let mod = arr.length % chunkCount; + let tempChunkSize = chunkSize; + + for (let i = 0; i < arrLength; i += tempChunkSize) { + tempChunkSize = chunkSize; + if (mod > 0) { + tempChunkSize = chunkSize + 1; + mod--; + } + chunk = arr.slice(i, i + tempChunkSize); + tempArray.push(chunk); + } + + return tempArray; +} + +createClusters({ + clientId: '174603832993513472', + shardCount: 2, + clusterCount: 2, + env: 'dev', +}); + +createClusters({ + clientId: '161660517914509312', + shardCount: 1152, + env: 'prod', +}); + +createClusters({ + clientId: '168274214858653696', + shardCount: 16, + clusterCount: 16, + env: 'premium', +}); + +createClusters({ + clientId: '347378090399236096', + shardCount: 2, + clusterCount: 2, + env: 'alpha', +}); + +// createClusters({ +// clientId: '161660517914509312', +// shardCount: 1440, +// }); + +// clusters.forEach(c => console.log(JSON.stringify(c))); diff --git a/src/commands/Admin/Avatar.js b/src/commands/Admin/Avatar.js new file mode 100644 index 0000000..bbd54c7 --- /dev/null +++ b/src/commands/Admin/Avatar.js @@ -0,0 +1,37 @@ +'use strict'; + +const axios = require('axios'); +const {Command} = require('@dyno.gg/dyno-core'); + +class SetAvatar extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['setavatar', 'setav']; + this.group = 'Admin'; + this.description = 'Set the bot avatar'; + this.usage = 'avatar [url]'; + this.permissions = 'admin'; + this.extraPermissions = [this.config.owner || this.config.admin]; + this.expectedArgs = 1; + } + + async execute({ message, args }) { + try { + var res = await axios.get(args[0], { + header: { Accept: 'image/*' }, + responseType: 'arraybuffer', + }).then(response => `data:${response.headers['content-type']};base64,${response.data.toString('base64')}`); + } catch (err) { + return this.error(message.channel, 'Failed to get a valid image.'); + } + + console.log(res); + + return this.client.editSelf({ avatar: res }) + .then(() => this.success(message.channel, 'Changed avatar.')) + .catch(() => this.error(message.channel, 'Failed setting avatar.')); + } +} + +module.exports = SetAvatar; diff --git a/src/commands/Admin/Changelog.js b/src/commands/Admin/Changelog.js new file mode 100644 index 0000000..0a8d9f2 --- /dev/null +++ b/src/commands/Admin/Changelog.js @@ -0,0 +1,28 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Changelog extends Command { + + constructor(...args) { + super(...args); + + this.name = 'changelog'; + this.aliases = ['changelog']; + this.group = 'Admin'; + this.description = 'Add an item to the changelog.'; + this.usage = 'changelog [stuff]'; + this.overseerEnabled = true; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + execute({ message, args }) { + return this.models.Changelog.insert({ entry: args.join(' ') }) + .then(() => this.success(message.channel, `Entry added.`)) + .catch(err => this.error(message.channel, err)); + } +} + +module.exports = Changelog; diff --git a/src/commands/Admin/CommandStats.js b/src/commands/Admin/CommandStats.js new file mode 100644 index 0000000..1cf7d23 --- /dev/null +++ b/src/commands/Admin/CommandStats.js @@ -0,0 +1,65 @@ +/* eslint-disable no-unused-vars */ +'use strict'; + +const util = require('util'); +const {Command} = require('@dyno.gg/dyno-core'); + +class CommandStats extends Command { + + constructor(...args) { + super(...args); + + this.aliases = ['commandstats', 'cs']; + this.group = 'Admin'; + this.description = 'Get command stats for the past 7 days'; + this.usage = 'commandstats [command]'; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.expectedArgs = 0; + } + + async execute({ message, args }) { + let results = await this.models.CommandLog.aggregate([ + { $group : { _id: '$command', count: { $sum: 1 } } }, + ]).exec(); + + if (!results || !results.length) { + return this.error(message.channel, 'No results found.'); + } + + const commands = this.dyno.commands.filter(c => c.permissions === 'admin'); + const len = Math.max(...results.map(r => r._id.length)); + + results = results + .filter(r => !commands.find(c => c.name === r._id || c.aliases.includes(r._id))) + .sort((a, b) => (a.count < b.count) ? 1 : (a.count > b.count) ? -1 : 0) + .map(r => { // eslint-disable-line + return { name: r._id, count: r.count }; + }); + + const embed = { + fields: [], + timestamp: new Date(), + }; + + const start = 25 * (args[0] ? parseInt(args[0]) - 1 : 0); + + const res = results.splice(start, 25); + for (let cmd of res) { + embed.fields.push({ name: cmd.name, value: cmd.count.toString(), inline: true }); + } + + this.sendMessage(message.channel, { embed }); + + // const msgArray = this.utils.splitMessage(results, 1990); + + // for (let m of msgArray) { + // this.sendCode(message.channel, m, 'js'); + // } + + return Promise.resolve(); + } +} + +module.exports = CommandStats; diff --git a/src/commands/Admin/Data.js b/src/commands/Admin/Data.js new file mode 100644 index 0000000..977ddcc --- /dev/null +++ b/src/commands/Admin/Data.js @@ -0,0 +1,730 @@ +'use strict'; + +const each = require('async-each'); +const axios = require('axios'); +const moment = require('moment'); +const uuid = require('uuid/v4'); +const {Command} = require('@dyno.gg/dyno-core'); + +class Data extends Command { + + constructor(...args) { + super(...args); + + this.aliases = ['data']; + this.group = 'Admin'; + this.description = 'Get various stats and data.'; + this.defaultCommand = 'user'; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.hideFromHelp = true; + this.expectedArgs = 0; + + this.commands = [ + { name: 'user', desc: 'Get information about a user.', default: true }, + { name: 'premium', desc: 'Gets information about premium guilds of a user'}, + { name: 'guilds', desc: 'Get a list of guilds.' }, + { name: 'guild', desc: 'Get information about a guild.' }, + { name: 'mods', desc: 'Get moderations by user for the past month.' }, + { name: 'automod', desc: 'Get automod stats.' }, + { name: 'topshared', desc: 'Top list of bots with guild counts and shared guilds' }, + { name: 'addmodule', desc: 'NO' }, + { name: 'associate', desc: 'Potato' }, + { name: 'associates', desc: 'Potato' }, + { name: 'shards', desc: 'Shard stats' }, + { name: 'ishards', desc: 'Shard stats' }, + { name: 'cfg', desc: 'Potato' }, + { name: 'listing', desc: 'Get listing information for a server' }, + ]; + + this.usage = [ + 'data [user]', + 'data user [user]', + 'data guilds [page]', + 'data automod', + ]; + } + + permissionsFn({ message }) { + if (!message.member) return false; + if (message.guild.id !== this.config.dynoGuild) return false; + + if (this.isServerAdmin(message.member, message.channel)) return true; + if (this.isServerMod(message.member, message.channel)) return true; + + let allowedRoles = [ + '225209883828420608', // Accomplices + '355054563931324420', // Trusted + ]; + + const roles = message.guild.roles.filter(r => allowedRoles.includes(r.id)); + if (roles && message.member.roles.find(r => roles.find(role => role.id === r))) return true; + + return false; + } + + execute({ message }) { + return Promise.resolve(); + } + + async guilds({ message, args }) { + try { + var guilds = await this.models.Server.find({ deleted: false }) + .sort({ memberCount: -1 }) + .limit(25) + .skip(args[0] ? (args[0] - 1) * 25 : 0) + .lean() + .exec(); + } catch (err) { + return this.error(message.channel, err); + } + + if (!guilds || !guilds.length) { + return this.sendMessage(message.channel, 'No guilds returned.'); + } + + const embed = { + title: `Guilds - ${args[0] || 0}`, + fields: [], + }; + + for (let guild of guilds) { + embed.fields.push({ + name: guild.name, + value: `${guild._id}\t${guild.region}\t${guild.memberCount} members`, + inline: true, + }); + } + + return this.sendMessage(message.channel, { embed }); + } + + async guild({ message, args }) { + const clientOptions = this.dyno.clientOptions; + const shardCount = parseInt(args[1] || clientOptions.shardCount, 10); + // const firstShardId = parseInt(args[2] || clientOptions.firstShardId, 10); + // const lastShardId = parseInt(args[3] || clientOptions.lastShardId, 10); + const clusterCount = parseInt(args[4] || clientOptions.clusterCount, 10); + + try { + var guild = await this.models.Server.findOne({ _id: args[0] || message.channel.guild.id }).lean(); + } catch (err) { + return this.error(message.channel, err); + } + + if (!guild) { + return this.error(message.channel, 'No guild found.'); + } + + // const shardIds = [...Array(1 + lastShardId - firstShardId).keys()].map(v => firstShardId + v); + // const clusterShardCount = Math.ceil(shardIds.length / clusterCount); + // const shardCounts = this.chunkArray(shardIds, clusterShardCount); + + guild.shardId = ~~((args[0] / 4194304) % shardCount); + // guild.clusterId = shardCounts.findIndex(a => a.includes(guild.shardId)); + + if (guild.ownerID) { + var owner = await this.restClient.getRESTUser(guild.ownerID).catch(() => false); + } + + let premiumUser + if (guild.premiumUserId) { + premiumUser = await this.restClient.getRESTUser(guild.premiumUserId).catch(() => false); + } + + const embed = { + author: { + name: guild.name, + icon_url: guild.iconURL, + }, + fields: [ + // { name: 'Cluster', value: guild.clusterId.toString(), inline: true }, + { name: 'Shard', value: guild.shardId.toString(), inline: true }, + { name: 'Region', value: guild.region || 'Unknown', inline: true }, + { name: 'Members', value: guild.memberCount ? guild.memberCount.toString() : '0', inline: true }, + ], + footer: { text: `ID: ${guild._id}` }, + timestamp: new Date(), + }; + + embed.fields.push({ name: 'Prefix', value: guild.prefix || '?', inline: true }); + + embed.fields.push({ name: 'Mod Only', value: guild.modonly ? 'Yes' : 'No', inline: true }); + embed.fields.push({ name: 'Premium', value: guild.isPremium ? 'Yes' : 'No', inline: true }); + + embed.fields.push({ name: 'Owner ID', value: guild.ownerID || 'Unknown', inline: true }); + if (owner) { + embed.fields.push({ name: 'Owner', value: owner ? `${this.utils.fullName(owner)}` : guild.ownerID || 'Unknown', inline: true }); + } + + if (guild.premiumSince) { + embed.fields.push({ name: 'Premium Since', value: new Date(guild.premiumSince).toISOString().substr(0, 16), inline: true }); + } + + if (premiumUser) { + embed.fields.push({ name: 'Premium ID', value: `${premiumUser.id}`, inline: true }); + embed.fields.push({ name: 'Premium User', value: `${this.utils.fullName(premiumUser)}\n<@!${premiumUser.id}>`, inline: true }); + } + + if (guild.beta) { + embed.fields.push({ name: 'Beta', value: guild.beta ? 'Yes' : 'No', inline: true }); + } + + // START MODULES + const modules = this.dyno.modules.filter(m => !m.admin && !m.core && m.list !== false); + + if (!modules) { + return this.error(message.channel, `Couldn't get a list of modules.`); + } + + const enabledModules = modules.filter(m => !guild.modules.hasOwnProperty(m.name) || + guild.modules[m.name] === true); + const disabledModules = modules.filter(m => guild.modules.hasOwnProperty(m.name) && + guild.modules[m.name] === false); + + if (enabledModules.length) { + embed.fields.push({ name: 'Enabled Modules', value: enabledModules.map(m => m.name).join(', '), inline: false }); + } + if (disabledModules.length) { + embed.fields.push({ name: 'Disabled Modules', value: disabledModules.map(m => m.name).join(', '), inline: false }); + } + + embed.fields.push({ name: '\u200b', value: `[Dashboard](https://www.dynobot.net/server/${guild._id})`, inline: true }); + + return this.sendMessage(message.channel, { embed }); + } + + async listing({ message, args }) { + const guildId = args[0]; + + const coll = await this.db.collection('serverlist_store'); + const doc = await coll.findOne({ id: guildId }); + + if (!doc) { + return this.error(message.channel, 'No guild found.'); + } + + const embed = { + title: doc.name, + thumbnail: { + url: doc.icon, + }, + fields: [ + { + name: 'Description', + value: doc.description || 'null', + inline: false, + }, + { + name: 'Invite URL', + value: doc.inviteUrl || 'null', + inline: true, + }, + { + name: 'Language', + value: doc.serverLanguage || 'null', + inline: true, + }, + { + name: 'Categories', + value: doc.categoriesFlattened || 'null', + inline: true, + }, + { + name: 'Tags', + value: doc.tagsFlattened || 'null', + inline: true, + }, + { + name: 'Listed', + value: doc.listed || 'false', + inline: true, + }, + { + name: 'Blacklisted', + value: doc.blacklisted || 'false', + inline: true, + }, + ], + footer: { text: `ID: ${doc.id}` }, + }; + + return this.sendMessage(message.channel, { embed }); + } + + async user({ message, args }) { + if (args && args.length) { + var resolvedUser = this.resolveUser(message.channel.guild, args.join(' ')); + } + + if (!resolvedUser) { + resolvedUser = await this.dyno.restClient.getRESTUser(args[0]).catch(() => false); + } + + const userId = resolvedUser ? resolvedUser.id : args[0] || message.author.id; + const user = resolvedUser; + + let ownedGuilds, premiumGuilds + try { + var guilds = await this.models.Server + .find({ $or: [ { ownerID: userId }, { premiumUserId: userId } ]}) + .sort({ memberCount: -1 }) + .lean() + .exec(); + + ownedGuilds = guilds.filter((i) => i.ownerID === userId); + premiumGuilds = guilds.filter((i) => i.premiumUserId === userId); + + } catch (err) { + return this.error(`Unable to get guilds.`); + } + + const userEmbed = { + author: { + name: `${user.username}#${user.discriminator}`, + icon_url: resolvedUser.avatarURL, + }, + fields: [], + }; + + userEmbed.fields.push({ name: 'ID', value: user.id, inline: true }); + userEmbed.fields.push({ name: 'Name', value: user.username, inline: true }); + userEmbed.fields.push({ name: 'Discrim', value: user.discriminator, inline: true }); + userEmbed.fields.push({ name: 'Premium guilds:', value: premiumGuilds.length, inline: true }); + + await this.sendMessage(message.channel, { embed: userEmbed }); + + if (!ownedGuilds || !ownedGuilds.length) return Promise.resolve(); + + const embed = { + title: 'Owned Guilds', + fields: [], + }; + + // START MODULES + const modules = this.dyno.modules.filter(m => !m.admin && !m.core && m.list !== false); + + if (!modules) { + return this.error(message.channel, `Couldn't get a list of modules.`); + } + + for (const guild of ownedGuilds) { + let valArray = [ + `Region: ${guild.region}`, + `Members: ${guild.memberCount}`, + `Prefix: ${guild.prefix || '?'}`, + ]; + + if (guild.modonly) { + valArray.push(`Mod Only: true`); + } + if (guild.beta) { + valArray.push(`Beta: true`); + } + if (guild.isPremium) { + valArray.push(`Premium: true`); + } + if (guild.deleted) { + valArray.push(`Kicked/Deleted: true`); + } + + let disabledModules = modules.filter(m => guild.modules.hasOwnProperty(m.name) && guild.modules[m.name] === false); + + if (disabledModules && disabledModules.length) { + valArray.push(`Disabled Modules: ${disabledModules.map(m => m.name).join(', ')}`); + } + + valArray.push(`[Dashboard](https://www.dynobot.net/server/${guild._id})`); + + embed.fields.push({ + name: `${guild.name} (${guild._id})`, + value: valArray.join('\n'), + inline: false, + }); + } + + return this.sendMessage(message.channel, { embed }); + } + + async premium({ message, args }) { + if (args && args.length) { + var resolvedUser = this.resolveUser(message.channel.guild, args.join(' ')); + } + + if (!args[0]) { + resolvedUser = message.author; + } + + if (!resolvedUser) { + resolvedUser = await this.dyno.restClient.getRESTUser(args[0]).catch(() => false); + } + + const userId = resolvedUser ? resolvedUser.id : args[0] || message.author.id; + const user = resolvedUser; + + let premiumGuilds + try { + var guilds = await this.models.Server + .find({ premiumUserId: userId }) + .select({ _id: 1, name: 1, premiumUserId: 1 }) + // .find({ $or: [ { ownerID: userId }, { premiumUserId: userId } ]}) + .sort({ memberCount: -1 }) + .lean() + .exec(); + + premiumGuilds = guilds.filter((i) => i.premiumUserId === userId); + + } catch (err) { + return this.error(`Unable to get guilds.`); + } + + const userEmbed = { + author: { + name: `${user.username}#${user.discriminator}`, + icon_url: resolvedUser.avatarURL, + }, + fields: [], + }; + + userEmbed.fields.push({ name: 'ID', value: user.id, inline: true }); + userEmbed.fields.push({ name: 'Name', value: user.username, inline: true }); + userEmbed.fields.push({ name: 'Discrim', value: user.discriminator, inline: true }); + userEmbed.fields.push({ name: 'Premium guilds:', value: premiumGuilds.length, inline: true }); + + if (premiumGuilds.length) { + userEmbed.fields.push({ name: '\u200b', value: premiumGuilds.map(g => `${g.name} (${g._id})`).join('\n') }); + } + + return this.sendMessage(message.channel, { embed: userEmbed }); + } + + async automod({ message }) { + try { + var counts = await this.redis.hgetall('automod.counts'); + } catch (err) { + return this.error(message.channel, err); + } + + const embed = { + title: 'Automod Stats', + fields: [ + { name: 'All Automods', value: counts.any, inline: true }, + { name: 'Spam/Dup Chars', value: counts.spamdup, inline: true }, + { name: 'Caps', value: counts.manycaps, inline: true }, + { name: 'Bad Words', value: counts.badwords, inline: true }, + { name: 'Emojis', value: counts.manyemojis, inline: true }, + { name: 'Link Cooldown', value: counts.linkcooldown, inline: true }, + { name: 'Any Link', value: counts.anylink, inline: true }, + { name: 'Blacklist Link', value: counts.blacklistlink, inline: true }, + { name: 'Invite', value: counts.invite, inline: true }, + { name: 'Attach/Embed Spam', value: counts.attachments, inline: true }, + { name: 'Attach Cooldown', value: counts.attachcooldown, inline: true }, + { name: 'Rate Limit', value: counts.ratelimit, inline: true }, + { name: 'Chat Clearing', value: counts.spamclear, inline: true }, + { name: 'Light Mentions', value: counts.mentionslight, inline: true }, + { name: 'Mention Bans', value: counts.mentions, inline: true }, + { name: 'Auto Mutes', value: counts.mutes, inline: true }, + { name: 'Forced Mutes', value: counts.forcemutes, inline: true }, + ], + timestamp: new Date(), + }; + + return this.sendMessage(message.channel, { content: 'Note: Automod stats from Dec. 29, 2016', embed }); + } + + async mods({ message, args, guildConfig }) { + const modlog = guildConfig.moderation ? guildConfig.moderation.channel : null; + if (!modlog) { + return this.error(message.channel, 'No log channel set.'); + } + + const startTime = moment().subtract(1, 'months').unix() * 1000; + let messages; + + try { + const results = await this.client.getMessages(modlog, 1100); + if (!results) { + return this.error(message.channel, 'Unable to get results.'); + } + + messages = results.filter(r => r.timestamp >= startTime); + } catch (err) { + console.error(err); + return this.error(message.channel, 'Something went wrong.'); + } + + const groupedMessages = messages.reduce((a, b) => { + try { + const embed = b.embeds[0]; + const mod = embed.fields.find(f => f.name === 'Moderator'); + if (!mod) return null; + const modId = mod.value.replace(/[\D]/g, ''); + a[modId] = a[modId] || 0; + a[modId]++; + return a; + } catch (err) { + console.error(err); + } + }, {}); + + // `<@!${k}> ${groupedMessages[k]}` + let arr = Object.keys(groupedMessages).map(k => ({ id: k, count: groupedMessages[k] })); + arr = arr.sort((a, b) => b.count - a.count); + arr = arr.map(o => `<@!${o.id}> ${o.count}`); + + return this.sendMessage(message.channel, { embed: { + description: arr.join('\n'), + } }); + } + + invite({ message, args }) { + if (!args || !args.length) return this.error(message.channel, `No name or ID specified.`); + this.client.guilds.find(g => g.id === args[0] || g.name === args.join(' ')) + .createInvite({ max_age: 60 * 30 }) + .then(invite => this.success(message.channel, `https://discord.gg/${invite.code}`)) + .catch(() => this.error(message.channel, `Couldn't create invite.`)); + } + + async topshared({ message }) { + try { + const dres = await axios.get(`https://bots.discord.pw/api/bots`, { + headers: { + Authorization: this.config.dbots.key, + Accept: 'application/json', + }, + }); + const res = await axios.get(this.config.carbon.list); + var data = res.data; + var dbots = dres.data; + } catch (err) { + return this.logger.error(err); + } + + if (!data || !data.length) return; + + let i = 0; + + const list = data.map(bot => { + bot.botid = bot.botid; + bot.servercount = parseInt(bot.servercount); + return bot; + }) + .filter(bot => bot.botid > 1000 && bot.servercount >= 25000) + .sort((a, b) => (a.servercount < b.servercount) ? 1 : (a.servercount > b.servercount) ? -1 : 0); + // `${++i} ${this.utils.pad(bot.name, 12)} - ${bot.servercount}` + + return new Promise(async (resolve) => { + let bots = []; + for (let bot of list) { + bot.botid = bot.botid.replace('195244341038546948', '195244363339530240'); + let allShared = await this.ipc.awaitResponse('shared', { user: bot.botid }); + bot.shared = allShared.reduce((a, b) => { + a += parseInt(b.result); + return a; + }, 0); + bots.push(bot); + } + bots = bots.map(b => { + ++i; + return `${this.utils.pad('' + i, 2)} ${this.utils.pad(b.name, 12)} ${this.utils.pad('' + b.servercount, 6)} Guilds, ${this.utils.pad('' + b.shared, 5)} Shared`; + }); + this.sendCode(message.channel, bots.join('\n')); + return resolve(); + }); + } + + addmodule({ message, args }) { + if (!this.dyno.modules.has(args[0])) return this.error(message.channel, `That module does not exist.`); + if (this.config.moduleList.includes(args[0])) return this.error(message.channel, `That module is already loaded.`); + this.config.moduleList.push(args[0]); + if (this.config.disabledCommandGroups && this.config.disabledCommandGroups.includes(args[0])) { + let index = this.config.disabledCommandGroups.indexOf(args[0]); + let commandGroups = this.config.disabledCommandGroups.split(','); + commandGroups.splice(index, 1); + this.config.disabledCommandGroups = commandGroups.join(','); + return this.success(message.channel, `Added module ${args[0]} and removed the disabled command group.`); + } + + return this.success(message.channel, `Added module ${args[0]}.`); + } + + async shards({ message, args }) { + const instances = ['Titan', 'Atlas', 'Pandora']; + + try { + const instanceStats = await Promise.all([ + axios.get('http://prod01.dyno.lan:5000/shards'), + axios.get('http://prod02.dyno.lan:5000/shards'), + axios.get('http://prod03.dyno.lan:5000/shards'), + ]); + // const shardStats = await this.dyno.ipc.awaitResponse('shards'); + + let response = ''; + instanceStats.forEach((instance, idx) => { + response += `${instances[idx]}:\n\n`; + if (!instance || !instance.data) { + return; + } + for (let result of instance.data) { + const id = result.id; + const s = result.result; + if (!s || typeof s === 'string') { + response += `ID:${id} Error.\n`; + } else { + response += `ID:${id} SHARDS:${s.connectedCount}/${s.shardCount} GUILDS:${s.guildCount} (${s.unavailableCount} unavil) SHARDS:${JSON.stringify(s.shards)} VC:${s.voiceConnections} UPTIME:${s.uptime}\n`; + } + } + response += `\n`; + }); + + let msgArray = []; + msgArray = msgArray.concat(this.utils.splitMessage(response, 1980)); + + for (let m of msgArray) { + this.sendCode(message.channel, m, 'Haskell'); + } + + return Promise.resolve(); + } catch (err) { + console.log(err); + } + } + + async ishards({ message, args }) { + try { + const shardStats = await this.dyno.ipc.awaitResponse('shards'); + + let response = ''; + shardStats.forEach((result) => { + const id = result.id; + const s = result.result; + if (!s || typeof s === 'string') { + response += `ID:${id} Error.`; + } else { + response += `ID:${id} SHARDS:${s.connectedCount}/${s.shardCount} GUILDS:${s.guildCount} (${s.unavailableCount} unavil) SHARDS:${JSON.stringify(s.shards)} VC:${s.voiceConnections} UPTIME:${s.uptime}\n`; + } + }); + + let msgArray = []; + msgArray = msgArray.concat(this.utils.splitMessage(response, 1980)); + + for (let m of msgArray) { + this.sendCode(message.channel, m, 'Haskell'); + } + + return Promise.resolve(); + } catch (err) { + console.log(err); + } + } + + async associate({ message, args }) { + let associates = this.dyno.globalConfig.associates || []; + let o = associates.find(a => a.name.toLowerCase().search(args.join(' ').toLowerCase()) !== -1); + + let embed = { + color: this.utils.hexToInt('#3395d6'), + title: o.name, + url: o.links.find(l => l.name === 'Server Invite').value, + description: o.description, + image: { url: o.banner }, + fields: [ + { name: 'Links', value: o.links.map(l => `[${l.name}](${l.value})`).join('\n') }, + ], + footer: { + text: o.sponsor ? 'Sponsor' : 'Partner', + }, + }; + + await this.sendMessage(message.channel, { embed }); + } + + async associates({ message, args }) { + if (!message.member.roles.includes('203040224597508096')) { + return this.error(message.channel, 'Get off my potato!'); + } + + message.delete(); + + let associates = this.dyno.globalConfig.associates || []; + associates = this.utils.shuffleArray(associates); + + for (let o of associates) { + let embed = { + color: this.utils.hexToInt('#3395d6'), + title: o.name, + url: o.links.find(l => l.name === 'Server Invite').value, + description: o.description, + image: { url: o.banner }, + fields: [ + { name: 'Links', value: o.links.map(l => `[${l.name}](${l.value})`).join('\n') }, + ], + footer: { + text: o.sponsor ? 'Sponsor' : 'Partner', + }, + }; + + await this.sendMessage(message.channel, { embed }); + } + } + + async cfg({ message, args }) { + if (!this.isAdmin(message.author) && !message.member.roles.includes('355054563931324420')) { + return Promise.reject('Insufficient permissions'); + } + + if (!args || !args.length) { + return; + } + + const payload = { guildId: args[0], userId: message.member.id }; + try { + var uniqueId = uuid(); + } catch (err) { + return this.error(message.channel, err); + } + + try { + await this.redis.setex(`supportcfg:${uniqueId}`, 60, JSON.stringify(payload)); + } catch (err) { + this.logger.error(err); + return this.error(message.channel, 'Something went wrong. Try again later.'); + } + + const url = `https://dyno.gg/support/c/${uniqueId}`; + + return this.sendMessage(message.channel, url); + } + + permissionsFor({ message, args }) { + if (!args || !args.length) return this.error(message.channel, `No name or ID specified.`); + const guild = this.client.guilds.find(g => g.id === args[0] || g.name === args.join(' ')); + + if (!guild) { + return this.error(message.channel, `Couldn't find that guild.`); + } + + const perms = guild.members.get(this.client.user.id); + + const msgArray = this.utils.splitMessage(perms, 1950); + + for (let m of msgArray) { + this.sendCode(message.channel, m, 'js'); + } + } + + chunkArray(myArray, chunk_size) { + const arrayLength = myArray.length; + const tempArray = []; + let chunk = []; + + for (let index = 0; index < arrayLength; index += chunk_size) { + chunk = myArray.slice(index, index + chunk_size); + tempArray.push(chunk); + } + + return tempArray; + } +} + +module.exports = Data; diff --git a/src/commands/Admin/DisablePremium.js b/src/commands/Admin/DisablePremium.js new file mode 100644 index 0000000..b352bf9 --- /dev/null +++ b/src/commands/Admin/DisablePremium.js @@ -0,0 +1,130 @@ +'use strict'; + +const moment = require('moment'); +const {Command} = require('@dyno.gg/dyno-core'); + +class DisablePremium extends Command { + + constructor(...args) { + super(...args); + + this.name = 'dispremium'; + this.aliases = ['dispremium']; + this.group = 'Admin'; + this.description = 'Disable premium for a server.'; + this.usage = 'dispremium [server id] [reason]'; + this.overseerEnabled = true; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + permissionsFn({ message }) { + if (!message.member) return false; + if (message.guild.id !== this.config.dynoGuild) return false; + + if (this.isServerAdmin(message.member, message.channel)) return true; + if (this.isServerMod(message.member, message.channel)) return true; + + let allowedRoles = [ + '225209883828420608', // Accomplices + ]; + + const roles = message.guild.roles.filter(r => allowedRoles.includes(r.id)); + if (roles && message.member.roles.find(r => roles.find(role => role.id === r))) return true; + + return false; + } + + async execute({ message, args }) { + let resolvedUser = await this.resolveUser(message.guild, args[0]); + if (!resolvedUser) { + try { + resolvedUser = await this.dyno.restClient.getRESTUser(args[0]); + } catch (err) { + // pass + } + } + + if (resolvedUser) { + try { + const guilds = await this.models.Server.find({ premiumUserId: resolvedUser.id }, { _id: 1 }); + if (!guilds || !guilds.length) { + return this.sendMessage(message.channel, `That user has no premium guilds.`); + } + await Promise.all(guilds.map(g => this.disableGuild(message, g._id))); + return this.success(message.channel, `Disabled ${guilds.length} guilds for ${resolvedUser.username}#${resolvedUser.discriminator}`); + } catch (err) { + this.logger.error(err); + return this.error(message.channel, `Error: ${err.message}`); + } + } else { + return this.disableGuild(message, args[0]); + } + } + + async disableGuild(message, guildId) { + const logChannel = this.client.getChannel('231484392365752320'); + const dataChannel = this.client.getChannel('301131818483318784'); + + if (!logChannel || !dataChannel) { + return this.error(message.channel, 'Unable to find log channel.'); + } + + try { + await this.dyno.guilds.update(guildId, { $unset: { vip: 1, isPremium: 1, premiumUserId: 1, premiumSince: 1 } }); + } catch (err) { + this.logger.error(err); + return this.error(message.channel, `Error: ${err.message}`); + } + + try { + var doc = await this.models.Server.findOne({ _id: guildId }).lean().exec(); + } catch (e) { + this.logger.error(e); + return this.error(message.channel, `Error: ${e.message}`); + } + + this.success(logChannel, `[**${this.utils.fullName(message.author)}**] Disabled Premium on **${doc.name} (${doc._id})**`); + this.success(message.channel, `Disabled Dyno Premium on ${doc.name}`); + + message.delete().catch(() => false); + + const logDoc = { + serverID: doc._id, + serverName: doc.name, + ownerID: doc.ownerID, + userID: doc.premiumUserId || 'Unknown', + timestamp: new Date().getTime(), + type: 'disable', + } + + await this.dyno.db.collection('premiumactivationlogs').insert(logDoc); + return Promise.resolve(); + + try { + var messages = await this.client.getMessages(dataChannel.id, 500); + } catch (err) { + this.logger.error(e); + return this.error(message.channel, `Error: ${err.message}`); + } + + if (!messages || !messages.length) { + return Promise.resolve(); + } + + for (let msg of messages) { + let embed = msg.embeds[0]; + + if (embed.fields.find(f => f.name === 'Server ID' && f.value === doc._id)) { + embed.fields.push({ name: 'Disabled', value: moment().format('llll'), inline: true }); + // embed.fields.push({ name: 'Reason', value: reason, inline: true }); + } + + return msg.edit({ embed }).catch(err => this.logger.error(err)); + } + } +} + +module.exports = DisablePremium; + diff --git a/src/commands/Admin/EnablePremium.js b/src/commands/Admin/EnablePremium.js new file mode 100644 index 0000000..7f2ed65 --- /dev/null +++ b/src/commands/Admin/EnablePremium.js @@ -0,0 +1,107 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class EnablePremium extends Command { + + constructor(...args) { + super(...args); + + this.name = 'enpremium'; + this.aliases = ['enpremium']; + this.group = 'Admin'; + this.description = 'Enable premium for a server.'; + this.usage = 'enpremium [server id] [user]'; + this.overseerEnabled = true; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 2; + } + + permissionsFn({ message }) { + if (!message.member) return false; + if (message.guild.id !== this.config.dynoGuild) return false; + + if (this.isServerAdmin(message.member, message.channel)) return true; + if (this.isServerMod(message.member, message.channel)) return true; + + let allowedRoles = [ + '225209883828420608', // Accomplices + ]; + + const roles = message.guild.roles.filter(r => allowedRoles.includes(r.id)); + if (roles && message.member.roles.find(r => roles.find(role => role.id === r))) return true; + + return false; + } + + async execute({ message, args }) { + let user = this.resolveUser(message.guild, args.slice(1).join(' ')); + + if (!user) { + if (!isNaN(args[0])) { + user = await this.restClient.getRESTUser(args[0]); + } + if (!user) { + return this.error(message.channel, 'Unable to find that user.'); + } + } + + const logChannel = this.client.getChannel('231484392365752320'); + const dataChannel = this.client.getChannel('301131818483318784'); + + if (!logChannel || !dataChannel) { + return this.error(message.channel, 'Unable to find log channel.'); + } + + return this.dyno.guilds.update(args[0], { $set: { vip: true, isPremium: true, premiumUserId: user.id, premiumSince: new Date().getTime() } }) + .then(async () => { + try { + var doc = await this.models.Server.findOne({ _id: args[0] }).lean().exec(); + } catch (e) { + return this.logger.error(e); + } + + this.success(logChannel, `[**${this.utils.fullName(message.author)}**] Enabled Premium on **${doc.name} (${doc._id})** for ${user.mention}`); + this.success(message.channel, `Enabled Dyno Premium on ${doc.name} for ${this.utils.fullName(user)}.`); + + const embed = { + fields: [ + { name: 'Server ID', value: doc._id, inline: true }, + { name: 'Server Name', value: doc.name, inline: true }, + { name: 'Owner ID', value: doc.ownerID, inline: true }, + { name: 'User ID', value: user.id, inline: true }, + { name: 'Username', value: this.utils.fullName(user), inline: true }, + { name: 'Mention', value: user.mention, inline: true }, + { name: 'Member Count', value: `${doc.memberCount || 0}`, inline: true }, + { name: 'Region', value: `${doc.region || 'Unknown'}`, inline: true }, + ], + timestamp: new Date(), + }; + + const logDoc = { + serverID: doc._id, + serverName: doc.name, + ownerID: doc.ownerID, + userID: user.id, + username: this.utils.fullName(user), + memberCount: doc.memberCount || 0, + region: doc.region || 'Unknown', + timestamp: new Date().getTime(), + type: 'enable', + } + + await this.dyno.db.collection('premiumactivationlogs').insert(logDoc); + + this.sendMessage(dataChannel, { embed }) + message.delete().catch(() => false); + + }) + .catch(err => { + this.logger.error(err); + return this.error(message.channel, `Error: ${err.message}`); + }); + } +} + +module.exports = EnablePremium; diff --git a/src/commands/Admin/Eval.js b/src/commands/Admin/Eval.js new file mode 100644 index 0000000..3cb5b24 --- /dev/null +++ b/src/commands/Admin/Eval.js @@ -0,0 +1,74 @@ +/* eslint-disable no-unused-vars */ +'use strict'; + +const os = require('os'); +const util = require('util'); +const moment = require('moment-timezone'); +const {Command} = require('@dyno.gg/dyno-core'); + +class Eval extends Command { + + constructor(...args) { + super(...args); + + this.name = 'eval'; + this.aliases = ['eval', 'e']; + this.group = 'Admin'; + this.description = 'Evaluate js code from discord'; + this.usage = 'eval [javascript]'; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + permissionsFn({ message }) { + if (!message.author) return false; + if (!this.dyno.globalConfig || !this.dyno.globalConfig.developers) return false; + + if (this.dyno.globalConfig.developers.includes(message.author.id)) { + return true; + } + + return false; + } + + async execute({ message, args, guildConfig }) { + let msgArray = [], + msg = message, + dyno = this.dyno, + client = this.client, + config = this.config, + models = this.models, + redis = this.redis, + utils = this.utils, + result; + + try { + result = eval(args.join(' ')); + } catch (e) { + result = e; + } + + if (result && result.then) { + try { + result = await result; + } catch (err) { + result = err; + } + } + + if (!result) { + return Promise.resolve(); + } + + msgArray = msgArray.concat(this.utils.splitMessage(result, 1990)); + + for (let m of msgArray) { + this.sendCode(message.channel, m.toString().replace(this.config.client.token, 'potato'), 'js'); + } + + return Promise.resolve(); + } +} + +module.exports = Eval; diff --git a/src/commands/Admin/Exec.js b/src/commands/Admin/Exec.js new file mode 100644 index 0000000..85d1404 --- /dev/null +++ b/src/commands/Admin/Exec.js @@ -0,0 +1,50 @@ +'use strict'; + +const { exec } = require('child_process'); +const {Command} = require('@dyno.gg/dyno-core'); + +class Exec extends Command { + + constructor(...args) { + super(...args); + + this.name = 'exec'; + this.aliases = ['exec', 'ex']; + this.group = 'Admin'; + this.description = 'Execute a shell command'; + this.usage = 'exec [command]'; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + exec(command) { + return new Promise((resolve, reject) => { + exec(command, (err, stdout, stderr) => { + if (err) return reject(err); + return resolve(stdout || stderr); + }); + }); + } + + async execute({ message, args }) { + let msgArray = [], + result; + + try { + result = await this.exec(args.join(' ')); + } catch (err) { + result = err; + } + + msgArray = msgArray.concat(this.utils.splitMessage(result, 1990)); + + for (let m of msgArray) { + this.sendCode(message.channel, m, 'js'); + } + + return Promise.resolve(); + } +} + +module.exports = Exec; diff --git a/src/commands/Admin/Git.js b/src/commands/Admin/Git.js new file mode 100644 index 0000000..90de32b --- /dev/null +++ b/src/commands/Admin/Git.js @@ -0,0 +1,50 @@ +'use strict'; + +const { exec } = require('child_process'); +const {Command} = require('@dyno.gg/dyno-core'); + +class Git extends Command { + + constructor(...args) { + super(...args); + + this.name = 'git'; + this.aliases = ['git']; + this.group = 'Admin'; + this.description = 'Execute a git command'; + this.usage = 'git [stuff]'; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + exec(command) { + return new Promise((resolve, reject) => { + exec(command, (err, stdout, stderr) => { + if (err) return reject(err); + return resolve(stdout || stderr); + }); + }); + } + + async execute({ message, args }) { + let msgArray = [], + result; + + try { + result = await this.exec(`git ${args.join(' ')}`); + } catch (err) { + result = err; + } + + msgArray = msgArray.concat(this.utils.splitMessage(result, 1990)); + + for (let m of msgArray) { + this.sendCode(message.channel, m, 'js'); + } + + return Promise.resolve(); + } +} + +module.exports = Git; diff --git a/src/commands/Admin/GlobalDisable.js b/src/commands/Admin/GlobalDisable.js new file mode 100644 index 0000000..361d3c9 --- /dev/null +++ b/src/commands/Admin/GlobalDisable.js @@ -0,0 +1,50 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class GlobalDisable extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['disglobal']; + this.group = 'Admin'; + this.description = 'Disable a module or command globally'; + this.usage = 'disglobal [name]'; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + execute({ message, args }) { + const name = args.join(' '); + const module = this.dyno.modules.get(name); + const command = this.dyno.commands.get(name); + const globalConfig = this.dyno.globalConfig || {}; + const options = { new: true, upsert: true }; + + if (!module && !command) { + return this.sendMessage(message.channel, `Couldn't find module or command ${name}`); + } + + if (module) { + globalConfig.modules = globalConfig.modules || {}; + globalConfig.modules[name] = false; + return this.models.Dyno.findOneAndUpdate({}, globalConfig, options).then(doc => { + this.config.global = doc.toObject(); + this.success(message.channel, `Disabled module ${name}`); + }).catch(err => this.logger.error(err)); + } + + if (command) { + globalConfig.commands = globalConfig.commands || {}; + globalConfig.commands[name] = false; + return this.models.Dyno.findOneAndUpdate({}, globalConfig, options).then(doc => { + this.config.global = doc.toObject(); + this.success(message.channel, `Disabled command ${name}`); + }).catch(err => this.logger.error(err)); + } + + return Promise.resulve(); + } +} + +module.exports = GlobalDisable; diff --git a/src/commands/Admin/GlobalEnable.js b/src/commands/Admin/GlobalEnable.js new file mode 100644 index 0000000..d8d81a4 --- /dev/null +++ b/src/commands/Admin/GlobalEnable.js @@ -0,0 +1,50 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class GlobalEnable extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['englobal']; + this.group = 'Admin'; + this.description = 'Disable a module or command globally'; + this.usage = 'englobal [name]'; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + execute({ message, args }) { + const name = args.join(' '); + const module = this.dyno.modules.get(name); + const command = this.dyno.commands.get(name); + const globalConfig = this.dyno.globalConfig || {}; + const options = { new: true, upsert: true }; + + if (!module || !command) { + return this.sendMessage(message.channel, `Couldn't find module or command ${name}`); + } + + if (module) { + globalConfig.modules = globalConfig.modules || {}; + globalConfig.modules[name] = true; + return this.models.Dyno.findOneAndUpdate({}, globalConfig, options).then(doc => { + this.config.global = doc.toObject(); + this.success(message.channel, `Enabled module ${name}`); + }).catch(err => this.logger.error(err)); + } + + if (command) { + globalConfig.commands = globalConfig.commands || {}; + globalConfig.commands[name] = true; + return this.models.Dyno.findOneAndUpdate({}, globalConfig, options).then(doc => { + this.config.global = doc.toObject(); + this.success(message.channel, `Enabled command ${name}`); + }).catch(err => this.logger.error(err)); + } + + return Promise.resolve(); + } +} + +module.exports = GlobalEnable; diff --git a/src/commands/Admin/Guild.js b/src/commands/Admin/Guild.js new file mode 100644 index 0000000..48c58a0 --- /dev/null +++ b/src/commands/Admin/Guild.js @@ -0,0 +1,167 @@ +const { Command } = require('@dyno.gg/dyno-core'); +const axios = require('axios'); +const uuid = require('uuid/v4'); + +class Guild extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['guild']; + this.group = 'Admin'; + this.description = 'Get guild status'; + this.usage = 'guild [guild id]'; + this.cooldown = 3000; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.expectedArgs = 0; + } + + permissionsFn({ message }) { + if (message.guild.id === this.config.dynoGuild) return true; + + const allowedRoles = [ + '225209883828420608', + '355054563931324420', + '231095149508296704', + '203040224597508096', + ]; + + if (message.member && allowedRoles.find(r => message.member.roles.includes(r))) { + return true; + } + + return false; + } + + async getGuild(guildId) { + try { + const options = { + method: 'POST', + headers: { Authorization: this.dyno.globalConfig.apiToken }, + url: `https://premium.dyno.gg/api/guild/${guildId}`, + }; + + const response = await axios(options); + if (!response.data) { + return Promise.reject('Unable to retrieve data at this time.'); + } + return response.data; + } catch (err) { + return Promise.reject(err); + } + } + + async execute({ message, args }) { + const guildId = args[0] || message.guild.id; + let guild; + + try { + guild = await this.getGuild(guildId); + } catch (err) { + return this.error(message.channel, err); + } + + let payload = { guildId, userId: message.member.id }; + try { + var uniqueId = uuid(); + } catch (err) { + return this.error(message.channel, err); + } + + if (!this.isServerMod(message.member, message.channel)) { + payload.excludeKeys = ['customcommands', 'autoresponder']; + } + + try { + await this.redis.setex(`supportcfg:${uniqueId}`, 60, JSON.stringify(payload)); + } catch (err) { + this.logger.error(err); + return this.error(message.channel, 'Something went wrong. Try again later.'); + } + + const url = `https://dyno.gg/support/c/${uniqueId}`; + + const shardCount = this.dyno.globalConfig.shardCount; + const shard = ~~((guildId / 4194304) % shardCount); + + const desc = [ + { key: 'Server', value: guild.serverName }, + { key: 'Cluster', value: guild.cluster }, + { key: 'Shard', value: `${shard}/${shardCount}` }, + { key: 'Members', value: guild.memberCount.toString() }, + { key: 'Region', value: guild.region }, + { key: 'Prefix', value: guild.prefix || '?' }, + { key: 'Mod Only', value: guild.modonly ? 'Yes' : 'No' }, + { key: 'Owner', value: `${guild.owner.username}#${guild.owner.discriminator}\n(${guild.ownerID})` }, + ]; + + const color = guild.isPremium ? this.utils.getColor('premium') : this.utils.getColor('blue'); + + let status; + + if (guild.server.result && guild.server.result.shardStatus) { + const shardStatus = guild.server.result.shardStatus.find(s => s.id === shard); + if (shardStatus.status === 'disconnected') { + status = 'https://cdn.discordapp.com/emojis/313956276893646850.png?v=1'; + } else if (shardStatus.status === 'ready') { + status = 'https://cdn.discordapp.com/emojis/313956277808005120.png?v=1'; + } else { + status = 'https://cdn.discordapp.com/emojis/313956277220802560.png?v=1'; + } + } + + const embed = { + color, + author: { + name: guild.name, + icon_url: guild.iconURL, + }, + // description: , + fields: [ + { name: 'Server', value: desc.map(o => `**${o.key}:** ${o.value}`).join('\n'), inline: true }, + ], + footer: { text: `ID: ${guild._id}` }, + timestamp: new Date(), + }; + + if (status) { + embed.footer.icon_url = status; + } + + if (guild.premiumUser) { + const field = [ + { key: 'Premium', value: guild.isPremium ? 'Yes' : 'No' }, + { key: 'Premium Since', value: new Date(guild.premiumSince).toISOString().substr(0, 16) }, + { key: 'Premium User', value: `${guild.premiumUser.username}#${guild.premiumUser.discriminator}\n(${guild.premiumUser.id})` }, + { key: 'Premium Installed', value: guild.premiumInstalled ? 'Yes' : 'No' }, + ]; + embed.fields.push({ name: 'Premium', value: field.map(o => `**${o.key}:** ${o.value}`).join('\n'), inline: true }); + } + + // START MODULES + const modules = this.dyno.modules.filter(m => !m.admin && !m.core && m.list !== false); + + if (!modules) { + return this.error(message.channel, `Couldn't get a list of modules.`); + } + + const enabledModules = modules.filter(m => !guild.modules.hasOwnProperty(m.name) || + guild.modules[m.name] === true); + const disabledModules = modules.filter(m => guild.modules.hasOwnProperty(m.name) && + guild.modules[m.name] === false); + + if (enabledModules.length) { + embed.fields.push({ name: 'Enabled Modules', value: enabledModules.map(m => m.name).join(', '), inline: false }); + } + if (disabledModules.length) { + embed.fields.push({ name: 'Disabled Modules', value: disabledModules.map(m => m.name).join(', '), inline: false }); + } + + embed.fields.push({ name: '\u200b', value: `[Dashboard](https://dyno.gg/manage/${guild._id}) **|** [Config](${url})`, inline: true }); + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = Guild; diff --git a/src/commands/Admin/ListingBlacklist.js b/src/commands/Admin/ListingBlacklist.js new file mode 100644 index 0000000..4bb26f8 --- /dev/null +++ b/src/commands/Admin/ListingBlacklist.js @@ -0,0 +1,40 @@ +'use strict'; + +const axios = require('axios'); +const {Command} = require('@dyno.gg/dyno-core'); + +class ListingBlacklist extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['listingblacklist']; + this.group = 'Admin'; + this.description = 'Blacklists and unlists a server from the server-listing.'; + this.usage = 'listingblacklist guildid'; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.expectedArgs = 1; + this.cooldown = 1000; + } + + async execute({ message, args }) { + if (!this.isAdmin(message.member) && !this.isOverseer(message.member)) { + return this.error(`You're not authorized to use this command.`); + } + + const guildId = args[0]; + + const coll = await this.db.collection('serverlist_store'); + const doc = await coll.findOne({ id: guildId }); + + if (!doc) { + return this.error(message.channel, 'No guild found.'); + } + + await coll.updateOne({ id: guildId }, { $set: { blacklisted: true, listed: false } }); + + return this.success(message.channel, `Succesfully blacklisted & unlisted ${doc.name} - ${doc.id}`); + } +} + +module.exports = ListingBlacklist; diff --git a/src/commands/Admin/LoadCommand.js b/src/commands/Admin/LoadCommand.js new file mode 100644 index 0000000..c6a9a0c --- /dev/null +++ b/src/commands/Admin/LoadCommand.js @@ -0,0 +1,53 @@ +'use strict'; + +const path = require('path'); +const util = require('util'); +const {Command} = require('@dyno.gg/dyno-core'); + +class LoadCommand extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['loadcommand', 'loadcmd']; + this.group = 'Admin'; + this.description = 'Load a command.'; + this.usage = 'load [command]'; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + execute({ message, args }) { + if (!this.dyno) return false; + + if (args[0] === 'all') { + const promises = []; + for (let cmd of this.dyno.commands.values()) { + const name = `${cmd.group}/${cmd.constructor.name}`; + promises.push(this.loadCommand(message, name)); + } + + return Promise.all(promises) + .then(data => this.sendCode(message.channel, data.map(d => util.inspect(d)), 'js')) + .catch(err => this.sendCode(message.channel, err, 'js')); + } + + let path = args.length > 1 ? `../modules/${args[0]}/commands/${args[1]}` : args[0]; + + return this.loadCommand(message, path) + .then(data => this.sendCode(message.channel, data.map(d => util.inspect(d)), 'js')) + .catch(err => this.sendCode(message.channel, err, 'js')); + } + + loadCommand(message, cmd) { + let filePath = path.join(this.config.paths.commands, cmd); + filePath = filePath.endsWith('.js') ? filePath : filePath + '.js'; + + if (!this.utils.existsSync(filePath)) { + return this.error(message.channel, `File does not exist: ${filePath}`); + } + + return this.dyno.ipc.awaitResponse('reload', { type: 'commands', name: cmd }); + } +} + +module.exports = LoadCommand; diff --git a/src/commands/Admin/LoadController.js b/src/commands/Admin/LoadController.js new file mode 100644 index 0000000..d1013dd --- /dev/null +++ b/src/commands/Admin/LoadController.js @@ -0,0 +1,23 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class LoadController extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['loadc']; + this.group = 'Admin'; + this.description = 'Load a controller.'; + this.usage = 'loadc [controller]'; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + execute({ args }) { + const module = this.modules.get('API'); + module.loadController(args[0]); + } +} + +module.exports = LoadController; diff --git a/src/commands/Admin/LoadIPC.js b/src/commands/Admin/LoadIPC.js new file mode 100644 index 0000000..fca5378 --- /dev/null +++ b/src/commands/Admin/LoadIPC.js @@ -0,0 +1,35 @@ +'use strict'; + +const path = require('path'); +const util = require('util'); +const {Command} = require('@dyno.gg/dyno-core'); + +class LoadIPC extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['loadipc']; + this.group = 'Admin'; + this.description = 'Load an ipc command.'; + this.usage = 'loadipc [command]'; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + execute({ message, args }) { + if (!this.dyno) return false; + + let filePath = path.join(this.config.paths.ipc, args[0]); + filePath = filePath.endsWith('.js') ? filePath : filePath + '.js'; + + if (!this.utils.existsSync(filePath)) { + return this.error(message.channel, `File does not exist: ${filePath}`); + } + + return this.dyno.ipc.awaitResponse('reload', { type: 'ipc', name: args[0] }) + .then(data => this.sendCode(message.channel, data.map(d => util.inspect(d)), 'js')) + .catch(err => this.sendCode(message.channel, err, 'js')); + } +} + +module.exports = LoadIPC; diff --git a/src/commands/Admin/LoadModule.js b/src/commands/Admin/LoadModule.js new file mode 100644 index 0000000..b9f96cd --- /dev/null +++ b/src/commands/Admin/LoadModule.js @@ -0,0 +1,27 @@ +'use strict'; + +const util = require('util'); +const {Command} = require('@dyno.gg/dyno-core'); + +class LoadModule extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['loadmodule', 'loadmod']; + this.group = 'Admin'; + this.description = 'Load a module.'; + this.usage = 'loadmodule [module]'; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + execute({ message, args }) { + if (!this.dyno) return false; + + return this.dyno.ipc.awaitResponse('reload', { type: 'modules', name: args[0] }) + .then(data => this.sendCode(message.channel, data.map(d => util.inspect(d)), 'js')) + .catch(err => this.sendCode(message.channel, err, 'js')); + } +} + +module.exports = LoadModule; diff --git a/src/commands/Admin/MoveCluster.js b/src/commands/Admin/MoveCluster.js new file mode 100644 index 0000000..4a40293 --- /dev/null +++ b/src/commands/Admin/MoveCluster.js @@ -0,0 +1,47 @@ +const { Command } = require('@dyno.gg/dyno-core'); +const { Client } = require('../../core/rpc'); + +class MoveCluster extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['clmove']; + this.group = 'Admin'; + this.description = 'Move a cluster from one server to another.'; + this.usage = 'clmove'; + this.permissions = 'admin'; + this.expectedArgs = 3; + this.cooldown = 30000; + } + + async execute({ message, args }) { + if (!this.isAdmin(message.member)) { + return this.error(`You're not authorized to use this command.`); + } + + try { + if (!isNaN(args[1])) { + const clusterId = parseInt(args[1], 10); + const cluster = await this.db.collection('clusters').findOne({ env: args[0], id: clusterId }); + + if (!cluster) { + return this.error(message.channel, `Unable to find cluster ${args[1]}`); + } + + const host = cluster.host.hostname; + const client = new Client(host, 5052); + + let response = await client.request('moveCluster', { id: cluster.id, name: args[2], token: this.config.restartToken }); + + return this.success(message.channel, `Moving cluster ${cluster.id} to ${args[2]}`); + } else { + return this.error(message.channel, 'Inavlid cluster id.'); + } + } catch (err) { + this.logger.error(err); + return this.error(message.channel, err); + } + } +} + +module.exports = MoveCluster; diff --git a/src/commands/Admin/Partner.js b/src/commands/Admin/Partner.js new file mode 100644 index 0000000..cde450f --- /dev/null +++ b/src/commands/Admin/Partner.js @@ -0,0 +1,39 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Partner extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['partner']; + this.group = 'Admin'; + this.description = 'Create a partner invite/id'; + this.usage = 'partner'; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.expectedArgs = 0; + } + + async execute({ message }) { + const channel = message.channel.guild.channels.find(c => c.name === 'welcome'); + + if (!channel) { + return this.error(message.channel, `I can't find the welcome channel.`); + } + + return this.client.createChannelInvite(channel.id, { temporary: false, unique: true, maxAge: 0 }) + .catch(err => this.error(message.channel, err)) + .then(invite => { + const content = [ + `ID: ${invite.code}`, + `Invite: https://discord.gg/${invite.code}`, + `Website: https://www.dynobot.net/?r=${invite.code}`, + ]; + + this.sendMessage(message.channel, content); + }); + } +} + +module.exports = Partner; diff --git a/src/commands/Admin/RLReset.js b/src/commands/Admin/RLReset.js new file mode 100644 index 0000000..d334719 --- /dev/null +++ b/src/commands/Admin/RLReset.js @@ -0,0 +1,68 @@ +const { Command } = require('@dyno.gg/dyno-core'); + +class RLReset extends Command { + + constructor(...args) { + super(...args); + + this.aliases = ['rlreset']; + this.group = 'Admin'; + this.description = 'Get various stats and data.'; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.hideFromHelp = true; + this.expectedArgs = 0; + this.cooldown = 120000; + } + + permissionsFn({ message }) { + if (!message.author) return false; + if (message.guild.id !== this.config.dynoGuild) return false; + if (!this.dyno.globalConfig || !this.dyno.globalConfig.contributors) return false; + + const contribs = this.dyno.globalConfig.contributors.map(c => c.id); + + if (!contribs || !contribs.length) return false; + + if (contribs.includes(message.author.id)) { + return true; + } + + return false; + } + + async execute({ message, args }) { + if (!args || !args.length) { + return this.error(message.channel, `Missing server and cluster`); + } + + try { + const [_env, _cluster, guildId] = args; + const clusterId = parseInt(_cluster, 10); + const cluster = await this.db.collection('clusters').findOne({ env: _env, id: clusterId }); + + if (!cluster) { + return this.error(message.channel, `Unable to find cluster ${_cluster} on ${_env}`); + } + + const host = cluster.host.hostname; + const port = 30000 + clusterId; + const client = new this.dyno.RPCClient(this.dyno, host, port); + + client.request('rlreset', { token: this.config.rpcToken, id: guildId }, (err) => { + if (err) { + return this.error(message.channel, `Something went wrong.`); + } + + return this.success(message.channel, 'Success!'); + }); + + return Promise.resolve(); + } catch (err) { + this.logger.error(err); + return this.error(message.channel, `Something went wrong.`); + } + } +} + +module.exports = RLReset; diff --git a/src/commands/Admin/RemoteDebug.js b/src/commands/Admin/RemoteDebug.js new file mode 100644 index 0000000..fc81790 --- /dev/null +++ b/src/commands/Admin/RemoteDebug.js @@ -0,0 +1,70 @@ +const { Command } = require('@dyno.gg/dyno-core'); + +class RemoteDebug extends Command { + + constructor(...args) { + super(...args); + + this.name = 'rdebug'; + this.aliases = ['rdebug', 're']; + this.group = 'Admin'; + this.description = 'Remotely debug a cluster'; + this.usage = 're [host] [cluster] [code]'; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + permissionsFn({ message }) { + if (!message.author) return false; + if (!this.dyno.globalConfig || !this.dyno.globalConfig.developers) return false; + + if (this.dyno.globalConfig.developers.includes(message.author.id)) { + return true; + } + + return false; + } + + async execute({ message, args }) { + if (!args || !args.length) { + return this.error(message.channel, `Missing server/cluster`); + } + + try { + const [_env, _cluster, ...codeArr] = args; + const clusterId = parseInt(_cluster, 10); + const cluster = await this.db.collection('clusters').findOne({ env: _env, id: clusterId }); + + if (!cluster) { + return this.error(message.channel, `Unable to find cluster ${_cluster}`); + } + + const host = cluster.host.hostname; + const port = 30000 + clusterId; + const client = new this.dyno.RPCClient(this.dyno, host, port); + + client.request('debug', { token: this.config.rpcToken, code: codeArr.join(' ') }, (err, response) => { + if (err) { + return this.error(message.channel, `Something went wrong.`); + } + + let msgArray = [], + result = response.result; + + msgArray = msgArray.concat(this.utils.splitMessage(result, 1990)); + + for (let m of msgArray) { + this.sendCode(message.channel, m.toString().replace(this.config.client.token, 'potato'), 'js'); + } + + return Promise.resolve(); + }); + } catch (err) { + this.logger.error(err); + return this.error(message.channel, err); + } + } +} + +module.exports = RemoteDebug; diff --git a/src/commands/Admin/RemoteDiagnose.js b/src/commands/Admin/RemoteDiagnose.js new file mode 100644 index 0000000..b475d5b --- /dev/null +++ b/src/commands/Admin/RemoteDiagnose.js @@ -0,0 +1,121 @@ +const axios = require('axios'); +const { Command } = require('@dyno.gg/dyno-core'); + +class RemoteDiagnose extends Command { + + constructor(...args) { + super(...args); + + this.aliases = ['rd']; + this.group = 'Admin'; + this.description = 'Remote diagnose a command or module.'; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.hideFromHelp = true; + this.expectedArgs = 0; + this.cooldown = 5000; + } + + permissionsFn({ message }) { + if (!message.author) return false; + if (message.guild.id !== this.config.dynoGuild) return false; + if (!this.dyno.globalConfig || !this.dyno.globalConfig.contributors) return false; + + const contribs = this.dyno.globalConfig.contributors.map(c => c.id); + + if (!contribs || !contribs.length) return false; + + if (contribs.includes(message.author.id)) { + return true; + } + + return false; + } + + async getStatus() { + try { + const response = await axios.get('https://dyno.gg/api/status'); + if (!response.data) { + return Promise.reject('Unable to retrieve data at this time.'); + } + return response.data; + } catch (err) { + return Promise.reject(err); + } + } + + async execute({ message, args }) { + if (!args || !args.length) { + return this.error(message.channel, `Missing guild id and name`); + } + + let local = false, + params = args, + cluster, + host; + + if (args[0].length <= 3) { + local = true; + params = args.slice(1); + } + + const [guildId, ...rest] = params; + const name = rest.join(' '); + + if (!local) { + let shardCount = this.dyno.globalConfig.shardCount || this.dyno.clientOptions.shardCount; + const shard = ~~((guildId / 4194304) % shardCount); + const hostMap = { + titan: `titan.dyno.lan`, + atlas: `atlas.dyno.lan`, + pandora: `pandora.dyno.lan`, + hyperion: `hype.dyno.lan`, + enceladus: `prom.dyno.lan`, + janus: `janus.dyno.lan`, + local: `localhost`, + }; + + let servers; + + try { + servers = await this.getStatus(); + } catch (err) { + return this.error(message.channel, err); + } + + if (!servers) { + return this.error(message.channel, 'Unable to get servers.'); + } + + const serverName = Object.keys(servers).find(serverName => { + return servers[serverName].find(s => s.result && s.result.shards.includes(shard)); + }); + + if (!serverName) { + return this.error(`Unable to find shard.`); + } + + const server = servers[serverName].find(s => s.result && s.result.shards.includes(shard)); + host = hostMap[serverName.toLowerCase()]; + cluster = server.id; + } else { + cluster = args[0].replace(/([A-Z])([\d]+)/, '$2'); + host = 'localhost'; + } + + const port = 30000 + parseInt(cluster, 10); + const client = new this.dyno.RPCClient(this.dyno, host, port); + + client.request('diagnose', { token: this.config.rpcToken, id: guildId, name }, (err, response) => { + if (err) { + return this.error(message.channel, `Something went wrong.`); + } + + return this.sendMessage(message.channel, response.result || response.error); + }); + + return Promise.resolve(); + } +} + +module.exports = RemoteDiagnose; diff --git a/src/commands/Admin/Restart.js b/src/commands/Admin/Restart.js new file mode 100644 index 0000000..3cec45c --- /dev/null +++ b/src/commands/Admin/Restart.js @@ -0,0 +1,63 @@ +const { Command } = require('@dyno.gg/dyno-core'); +const { Client } = require('../../core/rpc'); + +class Restart extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['restart']; + this.group = 'Admin'; + this.description = 'Restart shards.'; + this.usage = 'restart'; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.expectedArgs = 0; + this.cooldown = 30000; + } + + async execute({ message, args }) { + if (!this.isAdmin(message.member) && !this.isOverseer(message.member)) { + return this.error(`You're not authorized to use this command.`); + } + + const hostMap = { + dev: 'localhost', + }; + + try { + if (!isNaN(args[1])) { + const clusterId = parseInt(args[1], 10); + const cluster = await this.db.collection('clusters').findOne({ env: args[0], id: clusterId }); + + if (!cluster) { + return this.error(message.channel, `Unable to find cluster ${args[1]}`); + } + + const host = cluster.host.hostname; + const client = new Client(host, 5052); + + client.request('restart', { id: cluster.id, token: this.config.restartToken }); + return this.success(message.channel, `Restarting cluster ${cluster.id}.`); + } else { + const host = hostMap[args[0]] || `${args[0]}.dyno.lan`; + switch (args[1]) { + case 'manager': { + const client = new Client(host, 5050); + client.request('restartManager', {}); + return this.success(message.channel, `Restarting cluster manager on ${args[0]}.`); + } + case 'all': { + const client = new Client(host, 5052); + client.request('restart', { id: 'all', token: this.config.restartToken }); + return this.success(message.channel, `Restarting all clusters on ${args[0]}.`); + } + } + } + } catch (err) { + this.logger.error(err); + return this.error(message.channel, err); + } + } +} + +module.exports = Restart; diff --git a/src/commands/Admin/Sessions.js b/src/commands/Admin/Sessions.js new file mode 100644 index 0000000..a85deac --- /dev/null +++ b/src/commands/Admin/Sessions.js @@ -0,0 +1,69 @@ +'use strict'; + +const { Command } = require('@dyno.gg/dyno-core'); +const moment = require('moment'); + +require('moment-duration-format'); + +class Sessions extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['sessions']; + this.group = 'Admin'; + this.description = 'Get session data'; + this.usage = 'uptime'; + this.cooldown = 10000; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.overseerEnabled = true; + this.expectedArgs = 0; + } + + permissionsFn({ message }) { + if (!message.member) return false; + if (message.guild.id !== this.config.dynoGuild) return false; + + if (this.isServerAdmin(message.member, message.channel)) return true; + if (this.isServerMod(message.member, message.channel)) return true; + + let allowedRoles = [ + '225209883828420608', // Accomplices + '222393180341927936', // Regulars + '355054563931324420', // Trusted + ]; + + const roles = message.guild.roles.filter(r => allowedRoles.includes(r.id)); + if (roles && message.member.roles.find(r => roles.find(role => role.id === r))) return true; + + return false; + } + + async execute({ message }) { + try { + var data = await this.client.getBotGateway(); + } catch (err) { + return this.error(message.channel, err); + } + + let resetAfter = moment.duration(data.session_start_limit.reset_after, 'milliseconds'), + resetAfterDate = moment().subtract(data.session_start_limit.reset_after, 'milliseconds').format('llll'); + + const embed = { + color: this.utils.getColor('blue'), + title: 'Session Data', + fields: [ + { name: 'Recommended Shards', value: data.shards.toString(), inline: true }, + { name: 'Session Limit', value: data.session_start_limit.total.toString(), inline: true }, + { name: 'Session Remaining', value: data.session_start_limit.remaining.toString(), inline: true }, + { name: 'Reset After', value: resetAfter.format('d [days], h [hrs], m [min], s [sec]') }, + { name: 'Reset After Date', value: resetAfterDate }, + ], + timestamp: (new Date()).toISOString(), + }; + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = Sessions; diff --git a/src/commands/Admin/Speedtest.js b/src/commands/Admin/Speedtest.js new file mode 100644 index 0000000..088e8f1 --- /dev/null +++ b/src/commands/Admin/Speedtest.js @@ -0,0 +1,33 @@ +'use strict'; + +const { exec } = require('child_process'); +const {Command} = require('@dyno.gg/dyno-core'); + +class Speedtest extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['speedtest', 'speed']; + this.group = 'Admin'; + this.description = 'Get the result of a speed test.'; + this.usage = 'speedtest'; + this.permissions = 'admin'; + this.extraPermissions = [this.config.owner || this.config.admin]; + this.overseerEnabled = true; + this.expectedArgs = 0; + } + + execute({ message }) { + return this.sendMessage(message.channel, '```Running speed test...```').then(m => { + exec('/usr/bin/speedtest --simple --share', (err, stdout) => { + if (err) return m.edit('An error occurred.'); + return m.edit('```\n' + stdout + '\n```'); + }); + }).catch(err => { + if (this.config.self) return this.logger.error(err); + return this.error(message.channel, 'Unable to get speedtest.'); + }); + } +} + +module.exports = Speedtest; diff --git a/src/commands/Admin/UnloadModule.js b/src/commands/Admin/UnloadModule.js new file mode 100644 index 0000000..cb66283 --- /dev/null +++ b/src/commands/Admin/UnloadModule.js @@ -0,0 +1,27 @@ +'use strict'; + +const util = require('util'); +const {Command} = require('@dyno.gg/dyno-core'); + +class UnloadModule extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['unloadmodule', 'unloadmod']; + this.group = 'Admin'; + this.description = 'Unload a module.'; + this.usage = 'unloadmodule [module]'; + this.permissions = 'admin'; + this.expectedArgs = 1; + } + + execute({ message, args }) { + if (!this.dyno) return false; + + return this.dyno.ipc.awaitResponse('unload', { type: 'modules', name: args[0] }) + .then(data => this.sendCode(message.channel, data.map(d => util.inspect(d)), 'js')) + .catch(err => this.sendCode(message.channel, err, 'js')); + } +} + +module.exports = UnloadModule; diff --git a/src/commands/Admin/Update.js b/src/commands/Admin/Update.js new file mode 100644 index 0000000..66ecaf9 --- /dev/null +++ b/src/commands/Admin/Update.js @@ -0,0 +1,53 @@ +'use strict'; + +const { exec } = require('child_process'); +const {Command} = require('@dyno.gg/dyno-core'); + +class Update extends Command { + + constructor(...args) { + super(...args); + + this.name = 'update'; + this.aliases = ['update']; + this.group = 'Admin'; + this.description = 'Update the bot'; + this.usage = 'update (branch)'; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 0; + } + + exec(command) { + return new Promise((resolve, reject) => { + exec(command, (err, stdout, stderr) => { + if (err) return reject(err); + return resolve(stdout || stderr); + }); + }); + } + + async execute({ message, args }) { + let msgArray = [], + result; + + const branch = args && args.length ? args[0] : 'develop'; + this.sendMessage(message.channel, `Pulling the latest from ${branch}...`); + + try { + result = await this.exec(`git pull origin ${branch}; gulp build`); + } catch (err) { + result = err; + } + + msgArray = msgArray.concat(this.utils.splitMessage(result, 1990)); + + for (let m of msgArray) { + this.sendCode(message.channel, m, 'js'); + } + + return Promise.resolve(); + } +} + +module.exports = Update; diff --git a/src/commands/Admin/UpdateTeam.js b/src/commands/Admin/UpdateTeam.js new file mode 100644 index 0000000..03da5a1 --- /dev/null +++ b/src/commands/Admin/UpdateTeam.js @@ -0,0 +1,94 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class UpdateTeam extends Command { + + constructor(...args) { + super(...args); + + this.name = 'updateteam'; + this.aliases = ['updateteam']; + this.group = 'Admin'; + this.description = 'Update team data for the website'; + this.usage = 'updateteam'; + this.hideFromHelp = true; + this.permissions = 'admin'; + this.expectedArgs = 0; + } + + parseUser(user, size) { + return { + id: user.id, + name: `${user.username}#${user.discriminator}`, + avatar: user.dynamicAvatarURL(null, size), + }; + } + + hasRole(member, roleId) { + if (!member.roles.includes(roleId)) { + return false; + } + + const roleIds = [ + '203040224597508096', + '250182695693320193', + '231095149508296704', + '225209883828420608', + '355054563931324420' + ]; + const roleIndex = roleIds.indexOf(roleId); + const excludeIds = roleIds.filter(id => roleIds.indexOf(id) < roleIndex); + + return !excludeIds.filter(id => member.roles.includes(id)).length; + } + + updateCoreMember(members, user) { + let member = members.find(m => m.id === user.id); + user = Object.assign(user, member); + return user; + } + + async execute({ message, args }) { + const guild = message.channel.guild; + const roles = { + contributors: guild.members + .filter(m => this.hasRole(m, '250182695693320193')) + .map(m => this.parseUser(m.user, 128)) + .sort((a, b) => a.id - b.id), + moderators: guild.members + .filter(m => this.hasRole(m, '231095149508296704')) + .map(m => this.parseUser(m.user, 128)) + .sort((a, b) => a.id - b.id), + accomplices: guild.members + .filter(m => this.hasRole(m, '225209883828420608')) + .map(m => this.parseUser(m.user, 128)) + .sort((a, b) => a.id - b.id), + support: guild.members + .filter(m => this.hasRole(m, '355054563931324420')) + .map(m => this.parseUser(m.user, 128)) + .sort((a, b) => a.id - b.id), + }; + + const coreMembers = guild.members + .filter(m => m.roles.includes('203040224597508096')) + .map(m => this.parseUser(m.user, 256)); + + try { + const globalConfig = await this.models.Dyno.findOne({}, { team: 1 }).lean().exec(); + for (let [key, val] of Object.entries(roles)) { + globalConfig.team[key] = val; + } + globalConfig.team.core = globalConfig.team.core.map(user => this.updateCoreMember(coreMembers, user)); + + await this.models.Dyno.update({}, { $set: { team: globalConfig.team } }); + + return this.success(message.channel, 'Updated team members.'); + } catch (err) { + this.logger.error(err); + return this.error(message.channel, 'An error occurred.'); + } + } +} + +module.exports = UpdateTeam; diff --git a/src/commands/Admin/Username.js b/src/commands/Admin/Username.js new file mode 100644 index 0000000..5afc76a --- /dev/null +++ b/src/commands/Admin/Username.js @@ -0,0 +1,25 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Username extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['username', 'un']; + this.group = 'Admin'; + this.description = 'Change the bot username.'; + this.usage = 'username [new username]'; + this.permissions = 'admin'; + this.extraPermissions = [this.config.owner || this.config.admin]; + this.expectedArgs = 1; + } + + execute({ message, args }) { + return this.client.editSelf({ username: args.join(' ') }) + .then(() => this.success(message.channel, `Username changed to ${args.join(' ')}`)) + .catch(() => this.error(message.channel, 'Unable to change username.')); + } +} + +module.exports = Username; diff --git a/src/commands/Info/Info.js b/src/commands/Info/Info.js new file mode 100644 index 0000000..c91bd7c --- /dev/null +++ b/src/commands/Info/Info.js @@ -0,0 +1,76 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); +const moment = require('moment'); + +require('moment-duration-format'); + +class Info extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['info']; + this.group = 'Info'; + this.description = 'Get bot info.'; + this.usage = 'info'; + this.cooldown = 60000; + this.expectedArgs = 0; + this.noDisable = true; + this.sendDM = true; + } + + async execute({ message }) { + const uptime = moment.duration(process.uptime(), 'seconds'); + const cluster = this.dyno.clientOptions.clusterId.toString(); + const uptimeText = uptime.format('w [weeks] d [days], h [hrs], m [min], s [sec]'); + const footer = `${this.config.stateName} | Cluster ${cluster} | Shard ${message.channel.guild.shard.id} | Uptime ${uptimeText}`; + + const embed = { + color: this.utils.hexToInt('#3395d6'), + author: { + name: 'Dyno', + url: 'https://www.dyno.gg', + icon_url: `${this.config.avatar}?r=${this.config.version}`, + }, + fields: [], + footer: { + text: footer, + }, + }; + + embed.fields.push({ name: 'Version', value: this.config.version, inline: true }); + embed.fields.push({ name: 'Library', value: this.config.lib, inline: true }); + embed.fields.push({ name: 'Creator', value: this.dyno.globalConfig.author, inline: true }); + + try { + const [res, guildCounts] = await Promise.all([ + this.redis.hgetall(`dyno:stats:${this.config.state}`), + this.redis.hgetall(`dyno:guilds:${this.config.client.id}`), + ]); + + let guildCount = Object.values(guildCounts).reduce((a, b) => a += parseInt(b), 0); + + let shards = []; + for (const key in res) { + const shard = JSON.parse(res[key]); + shards.push(shard); + } + + const userCount = this.utils.sumKeys('users', shards); + + embed.fields.push({ name: 'Servers', value: guildCount.toString(), inline: true }); + embed.fields.push({ name: 'Users', value: userCount.toString(), inline: true }); + } catch (err) { + this.logger.error(err); + } + + embed.fields.push({ name: 'Website', value: '[dyno.gg](https://www.dyno.gg)', inline: true }); + embed.fields.push({ name: 'Invite', value: '[dyno.gg/invite](https://www.dyno.gg/invite)', inline: true }); + embed.fields.push({ name: 'Discord', value: '[dyno.gg/discord](https://www.dyno.gg/discord)', inline: true }); + embed.fields.push({ name: 'Donate', value: '[dyno.gg/donate](https://www.dyno.gg/donate)', inline: true }); + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = Info; diff --git a/src/commands/Info/Ping.js b/src/commands/Info/Ping.js new file mode 100644 index 0000000..de6729f --- /dev/null +++ b/src/commands/Info/Ping.js @@ -0,0 +1,30 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Ping extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['ping']; + this.group = 'Info'; + this.description = 'Ping the bot'; + this.usage = 'ping'; + this.hideFromHelp = true; + this.noDisable = true; + this.cooldown = 3000; + this.expectedArgs = 0; + } + + execute({ message }) { + let start = Date.now(); + + return this.sendMessage(message.channel, 'Pong! ') + .then(msg => { + let diff = (Date.now() - start); + return msg.edit(`Pong! \`${diff}ms\``); + }); + } +} + +module.exports = Ping; diff --git a/src/commands/Info/Premium.js b/src/commands/Info/Premium.js new file mode 100644 index 0000000..f21cfbd --- /dev/null +++ b/src/commands/Info/Premium.js @@ -0,0 +1,47 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Premium extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['premium']; + this.group = 'Info'; + this.description = 'Dyno premium information. (Responds in DM)'; + this.usage = 'premium'; + this.noDisable = true; + this.expectedArgs = 0; + this.cooldown = 60000; + } + + execute({ message }) { + let pref = '`▶`'; + const embed = { + color: this.utils.getColor('premium'), + author: { + name: 'Dyno Premium', + icon_url: 'https://cdn.dyno.gg/dyno-premium-64.png', + }, + description: [ + `Premium is an exclusive version of Dyno with premium features, and improved quality / uptime.`, + `It's also a great way to support Dyno development and hosting!`, + ].join('\n'), + fields: [ + { name: 'Features', value: [ + `${pref} Hosted on private/dedicated servers for 99.99% uptime.`, + `${pref} Volume control, playlists, Soundcloud, and more saved queues.`, + `${pref} Slowmode: Managing chat speed per user or channel.`, + `${pref} Autopurge: Purge messages at set times.`, + `${pref} Higher speed/performance and unnoticeable restarts or downtime.`, + `${pref} Fewer performance-based limits.`, + ].join('\n') }, + { name: 'Get Premium', value: `You can upgrade today at https://www.dynobot.net/upgrade` }, + ], + }; + + return this.sendDM(message.author.id, { embed }); + } +} + +module.exports = Premium; diff --git a/src/commands/Info/Stats.js b/src/commands/Info/Stats.js new file mode 100644 index 0000000..5644568 --- /dev/null +++ b/src/commands/Info/Stats.js @@ -0,0 +1,107 @@ +'use strict'; + +const os = require('os'); +const moment = require('moment'); +const { exec } = require('child_process'); +const {Command} = require('@dyno.gg/dyno-core'); + +require('moment-duration-format'); + +class Stats extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['stats']; + this.group = 'Info'; + this.description = 'Get bot stats.'; + this.usage = 'stats'; + this.hideFromHelp = true; + this.cooldown = 5000; + this.expectedArgs = 0; + } + + sumKeys(key, data) { + return data.reduce((a, b) => a + (b[key] ? parseInt(b[key], 10) : 0), 0); + } + + async execute({ message, args }) { + const stateMap = { + Lance: 0, + Beta: 1, + Lunar: 2, + Carti: 3, + API: 5, + Arsen: 6, + }; + + const idMap = Object.keys(stateMap).reduce((obj, key) => { + obj[stateMap[key]] = key; + return obj; + }, {}); + + let state = args.length ? (isNaN(args[0]) ? stateMap[args[0]] : args[0]) : this.config.state; + let stateName = args.length ? (isNaN(args[0]) ? args[0] : idMap[args[0]]) : this.config.stateName; + + if (!state || !stateName) { + state = this.config.state; + stateName = this.config.stateName; + } + + const [shards, guildCounts, vc] = await Promise.all([ + this.redis.hgetall(`dyno:cstats:${this.config.client.id}`), + this.redis.hgetall(`dyno:guilds:${this.config.client.id}`), + this.redis.hgetall(`dyno:vc`), // eslint-disable-line + ]).catch(() => false); + + const data = {}; + + data.shards = []; + for (const key in shards) { + const shard = JSON.parse(shards[key]); + data.shards.push(shard); + } + + data.guilds = Object.values(guildCounts).reduce((a, b) => a += parseInt(b), 0); + // data.guilds = this.sumKeys('guilds', data.shards); + data.users = this.sumKeys('users', data.shards); + // data.voiceConnections = this.sumKeys('voice', data.shards); + data.voice = this.sumKeys('voice', data.shards); + data.playing = this.sumKeys('playing', data.shards); + data.events = this.sumKeys('events', data.shards); + data.allConnections = [...Object.values(vc)].reduce((a, b) => a + parseInt(b), 0); + + let streams = this.config.isCore ? data.allConnections : `${data.playing}/${data.voice}`, + uptime = moment.duration(process.uptime(), 'seconds'), + footer = `PID ${process.pid} | ${stateName} | Cluster ${this.dyno.clientOptions.clusterId.toString()} | Shard ${message.channel.guild.shard.id}`; + + const embed = { + author: { + name: 'Dyno', + icon_url: `${this.config.avatar}`, + }, + fields: [ + { name: 'Guilds', value: data.guilds.toString(), inline: true }, + { name: 'Users', value: data.users.toString(), inline: true }, + { name: 'Streams', value: streams.toString(), inline: true }, + { name: 'Load Avg', value: os.loadavg().map(n => n.toFixed(3)).join(', '), inline: true }, + { name: 'Free Mem', value: `${this.utils.formatBytes(os.freemem())} / ${this.utils.formatBytes(os.totalmem())}`, inline: true }, + { name: 'Uptime', value: uptime.format('w [weeks] d [days], h [hrs], m [min], s [sec]'), inline: true }, + ], + footer: { + text: footer, + }, + timestamp: new Date(), + }; + + if (data.events) { + let events = Math.round(data.events / 15); + embed.fields.push({ name: 'Events/sec', value: `${events}/sec`, inline: true }); + } + + embed.fields = embed.fields.filter(f => f.value !== '0'); + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = Stats; diff --git a/src/commands/Info/Uptime.js b/src/commands/Info/Uptime.js new file mode 100644 index 0000000..58be910 --- /dev/null +++ b/src/commands/Info/Uptime.js @@ -0,0 +1,37 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); +const moment = require('moment'); + +require('moment-duration-format'); + +class Uptime extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['uptime', 'up']; + this.group = 'Info'; + this.description = 'Get bot uptime'; + this.usage = 'uptime'; + this.cooldown = 3000; + this.expectedArgs = 0; + } + + execute({ message }) { + let uptime = moment.duration(process.uptime(), 'seconds'), + started = moment().subtract(process.uptime(), 'seconds').format('llll'); + + const embed = { + color: this.utils.getColor('blue'), + title: 'Uptime', + description: uptime.format('w [weeks] d [days], h [hrs], m [min], s [sec]'), + footer: { + text: `PID ${process.pid} | ${this.config.stateName} | Cluster ${this.dyno.clientOptions.clusterId.toString()} | Shard ${message.channel.guild.shard.id} | Last started on ${started}`, + }, + }; + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = Uptime; diff --git a/src/commands/Misc/Avatar.js b/src/commands/Misc/Avatar.js new file mode 100644 index 0000000..04421bd --- /dev/null +++ b/src/commands/Misc/Avatar.js @@ -0,0 +1,40 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Avatar extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['avatar', 'av']; + this.group = 'Misc'; + this.description = `Get a users' avatar.`; + this.usage = 'avatar [user]'; + this.expectedArgs = 0; + this.cooldown = 3000; + } + + execute({ message, args }) { + let user = args.length ? this.resolveUser(message.channel.guild, args[0]) : message.author; + + if (!user) { + return this.error(message.channel, `Couldn't find that user.`); + } + + user = user.user || user; + + let avatar = user.dynamicAvatarURL(null, 256); + avatar = avatar.match(/.gif/) ? `${avatar}&f=.gif` : avatar; + + return this.sendMessage(message.channel, { embed: { + author: { + name: this.utils.fullName(user), + icon_url: user.dynamicAvatarURL(null, 32).replace(/\?size=.*/, ''), + }, + title: 'Avatar', + image: { url: avatar, width: 256, height: 256 }, + } }); + } +} + +module.exports = Avatar; diff --git a/src/commands/Misc/Botlist.js b/src/commands/Misc/Botlist.js new file mode 100644 index 0000000..4428cdd --- /dev/null +++ b/src/commands/Misc/Botlist.js @@ -0,0 +1,75 @@ +'use strict'; + +const axios = require('axios'); +const {Command} = require('@dyno.gg/dyno-core'); + +class Botlist extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['botlist']; + this.group = 'Misc'; + this.description = 'Gets the carbonitex bot list ordered by server counts'; + this.usage = 'botlist [page]'; + this.hideFromHelp = true; + this.cooldown = 5000; + this.expectedArgs = 0; + } + + async execute({ message, args }) { + let page = args[0] || 1, + i = 0; + + try { + const res = await axios.get(this.config.carbon.list); + var data = res.data; + } catch (err) { + return this.logger.error(err); + } + + let list = []; + if (this.dyno.botlist && (Date.now() - this.dyno.botlist.createdAt) < 300000) { + list = this.dyno.botlist.data; + } else { + list = data.map(bot => { + bot.botid = parseInt(bot.botid); + bot.servercount = parseInt(bot.servercount); + return bot; + }) + .filter(bot => bot.botid > 1000) + .sort((a, b) => (a.servercount < b.servercount) ? 1 : (a.servercount > b.servercount) ? -1 : 0) + .map(bot => { + let name = bot.name.includes('spoo.py') ? 'spoo.py' : bot.name; + let field = { + name: `${++i}. ${name}`, + value: `${bot.servercount} Servers`, + inline: true, + }; + return field; + }); + + this.dyno.botlist = { + createdAt: Date.now(), + data: list, + }; + } + + if (!list || !list.length) { + return this.error(message.channel, `Unable to get results`); + } + + let start = (page - 1) * 10; + + list = list.slice(start, start + 10); + + return this.sendMessage(message.channel, { embed: { + color: parseInt(('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6), 16), + description: `**Bot List (Page ${page}**)`, + fields: list, + footer: { text: 'Last Updated' }, + timestamp: new Date(this.dyno.botlist.createdAt), + } }); + } +} + +module.exports = Botlist; diff --git a/src/commands/Misc/Color.js b/src/commands/Misc/Color.js new file mode 100644 index 0000000..028de26 --- /dev/null +++ b/src/commands/Misc/Color.js @@ -0,0 +1,41 @@ +const { Command } = require('@dyno.gg/dyno-core'); + +class Color extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['color', 'colour']; + this.module = 'Misc'; + this.description = 'Show a color using hex.'; + this.usage = ['color #hex', 'color hex']; + this.example = ['color #ffffff', 'color ffffff']; + this.cooldown = 3000; + this.expectedArgs = 1; + } + hextoRGB(hex) { + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + return [r, g, b]; + } + execute({ message, args }) { + const hex = args[0].replace('#', ''); + const rgb = this.hextoRGB(hex); + if (rgb.includes(NaN)) return this.error(message.channel, 'Invalid color format!'); + const colorurl = `${this.config.colorapi.host}/color/${hex}/80x80.png`; + + return this.sendMessage(message.channel, { + embed: { + color: parseInt(`0x${hex}`), + fields: [ + { name: 'Hex', value: `#${hex}` }, + { name: 'RGB', value: `${rgb.join(', ')}` }, + ], + thumbnail: { url: colorurl }, + }, + }); + } +} + +module.exports = Color; diff --git a/src/commands/Misc/Discrim.js b/src/commands/Misc/Discrim.js new file mode 100644 index 0000000..079c2ed --- /dev/null +++ b/src/commands/Misc/Discrim.js @@ -0,0 +1,35 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Discrim extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['discrim']; + this.group = 'Misc'; + this.description = 'Gets a list of users with a discriminator'; + this.usage = 'discrim 1234'; + this.cooldown = 6000; + this.expectedArgs = 0; + } + + execute({ message, args }) { + const discrim = args.length ? args[0] : message.author.discriminator; + let users = this.client.users.filter(u => u.discriminator === discrim) + .map(u => this.utils.fullName(u)); + + if (!users || !users.length) { + return this.error(`I couldn't find any results for ${discrim}`); + } + + users = users.slice(0, 10); + + return this.sendMessage(message.channel, { embed: { + color: parseInt(('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6), 16), + description: users.join('\n'), + } }); + } +} + +module.exports = Discrim; diff --git a/src/commands/Misc/Distance.js b/src/commands/Misc/Distance.js new file mode 100644 index 0000000..7eb2294 --- /dev/null +++ b/src/commands/Misc/Distance.js @@ -0,0 +1,64 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Distance extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['distance']; + this.group = 'Misc'; + this.description = 'Get the distance between two sets of coordinates'; + this.usage = 'distance [coords] [coords]'; + this.cooldown = 3000; + this.expectedArgs = 2; + this.example = [ + 'distance 51.295978,-1.104938 45.407692,2.4415', + ]; + } + + deg2rad(deg) { + return deg * (Math.PI/180) + } + + getDistanceFromLatLonInKm(lat1,lon1,lat2,lon2) { + var R = 6371; + var dLat = this.deg2rad(lat2-lat1); + var dLon = this.deg2rad(lon2-lon1); + var a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(this.deg2rad(lat1)) * Math.cos(this.deg2rad(lat2)) * + Math.sin(dLon/2) * Math.sin(dLon/2); + var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + var d = R * c; + return d; + } + + execute({ message, args }) { + args = args.join(' ').replace(/, /g, ',').split(' '); + + let coords1 = args[0].split(','), + coords2 = args[1].split(','); + + if (!coords1 || !coords2 || coords1.length !== 2 || coords2.length !== 2) { + return this.error(message.channel, 'Invalid coordinates, please provide two coordinate pairs. See distance help for more info.'); + } + + let distance = this.getDistanceFromLatLonInKm(coords1[0], coords1[1], coords2[0], coords2[1]); + if (!distance) { + return this.error(message.channel, 'Invalid coordinates, please provide two coordinate pairs. See distance help for more info.'); + } + + const embed = { + color: this.utils.getColor('blue'), + fields: [ + { name: 'Lat/Lng 1', value: coords1.join(', '), inline: true }, + { name: 'Lat/Lng 2', value: coords2.join(', '), inline: true }, + { name: 'Distance (km)', value: distance.toFixed(2).toString() }, + ], + }; + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = Distance; diff --git a/src/commands/Misc/DynoAvatar.js b/src/commands/Misc/DynoAvatar.js new file mode 100644 index 0000000..75c33d2 --- /dev/null +++ b/src/commands/Misc/DynoAvatar.js @@ -0,0 +1,41 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class DynoAvatar extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['dynoavatar', 'dynoav']; + this.group = 'Misc'; + this.description = 'Generates a Dyno-like avatar.'; + this.usage = 'dynoav'; + this.cooldown = 10000; + this.expectedArgs = 0; + } + + execute({ message, args }) { + let user = args.length ? this.resolveUser(message.channel.guild, args[0]) : message.author; + + if (!user) { + return this.error(message.channel, `Couldn't find that user.`); + } + + user = user.user || user; + + let avatar = user.dynamicAvatarURL(null, 256); + const dynoAvatar = `${this.config.colorapi.host}/dynoav?url=${avatar}?r=1.1`; + // avatar = avatar.match(/.gif/) ? `${avatar}&f=.gif` : avatar; + + return this.sendMessage(message.channel, { embed: { + author: { + name: this.utils.fullName(user), + icon_url: user.dynamicAvatarURL(null, 32).replace(/\?size=.*/, ''), + }, + title: 'Avatar', + image: { url: dynoAvatar, width: 256, height: 256 }, + } }); + } +} + +module.exports = DynoAvatar; diff --git a/src/commands/Misc/Emotes.js b/src/commands/Misc/Emotes.js new file mode 100644 index 0000000..297bb2b --- /dev/null +++ b/src/commands/Misc/Emotes.js @@ -0,0 +1,52 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Emojis extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['emotes', 'emojis']; + this.group = 'Misc'; + this.description = 'Gets a list of server emojis.'; + this.usage = 'emotes'; + this.cooldown = 10000; + this.expectedArgs = 0; + } + + execute({ message, args }) { + let query; + + if (args && args.length > 0) { + query = args.join(' ').toLowerCase(); + } + + let emojis = message.guild.emojis; + + if (!emojis.length) { + return this.sendMessage(message.channel, `There are no emotes in this server.`); + } + + if (query) { + emojis = emojis.filter(e => e.name.toLowerCase().search(query) > -1); + } + + if (query && (!emojis || !emojis.length)) { + return this.sendMessage(message.channel, `I couldn't find any emotes.`); + } + + // console.log(emojis.map(e => `<:${e.name}:${e.id}>`).join(' ')); + const emojiCount = emojis.filter(e => !e.animated).length; + const animatedCount = emojis.filter(e => e.animated).length; + + const embed = { + color: this.utils.getColor('blue'), + title: `${emojiCount}${!query ? '/50' : ''} Emotes, ${animatedCount}${!query ? '/50' : ''} Animated`, + description: emojis.map(e => e.animated ? `` : `<:${e.name}:${e.id}>`).join(' '), + }; + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = Emojis; diff --git a/src/commands/Misc/InviteInfo.js b/src/commands/Misc/InviteInfo.js new file mode 100644 index 0000000..b41f230 --- /dev/null +++ b/src/commands/Misc/InviteInfo.js @@ -0,0 +1,94 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class InviteInfo extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['inviteinfo']; + this.group = 'Misc'; + this.description = 'Get information about an invite'; + this.usage = 'inviteinfo [code or invite]'; + this.cooldown = 6000; + this.hideFromHelp = true; + this.expectedArgs = 1; + + this._inviteRegex = new RegExp('(discordapp.com/invite|discord.me|discord.gg)(?:/#)?(?:/invite)?/([a-zA-Z0-9\-]+)'); // eslint-disable-line + } + + async execute({ message, args }) { + const match = args.join(' ').match(this._inviteRegex); + + let code = match ? match.pop() : args[0]; + + if (!match && !code) { + return this.error(`Invalid code or link.`); + } + + try { + var invite = await this.client.getInvite(code, true); + } catch (err) { + return this.error(message.channel, `Invalid code or link.`); + } + + if (!invite) { + return this.error(message.channel, `I can't get that invite.`); + } + + const embed = { + color: this.utils.getColor('blue'), + author: { + name: invite.guild.name, + icon_url: `https://cdn.discordapp.com/icons/${invite.guild.id}/${invite.guild.icon}.jpg?size=128`, + }, + fields: [], + footer: { text: `ID: ${invite.guild.id}` }, + }; + + if (invite.inviter) { + embed.fields.push({ name: 'Inviter', value: this.utils.fullName(invite.inviter), inline: true }); + } + + if (invite.channel) { + embed.fields.push({ name: 'Channel', value: `#${invite.channel.name}`, inline: true }); + } + + if (invite.memberCount) { + if (invite.presenceCount) { + embed.fields.push({ name: 'Members', value: `${invite.presenceCount}/${invite.memberCount}`, inline: true }); + } else { + embed.fields.push({ name: 'Members', value: `${invite.memberCount}`, inline: true }); + } + } + + if (message.guild.id === this.config.dynoGuild) { + try { + var inviteGuild = await this.models.Server.findOne({ _id: invite.guild.id }, { deleted: 1, ownerID: 1 }).lean().exec(); + } catch (err) { + // pass + } + + if (inviteGuild) { + embed.fields.push({ name: 'Dyno', value: inviteGuild.deleted === true ? 'Kicked' : 'In Server', inline: true }); + + if (inviteGuild.ownerID) { + var owner = this.client.users.get(inviteGuild.ownerID); + if (!owner) { + owner = await this.restClient.getRESTUser(inviteGuild.ownerID); + } + + if (owner) { + embed.fields.push({ name: 'Owner', value: this.utils.fullName(owner), inline: true }); + } + } + } else { + embed.fields.push({ name: 'Dyno', value: 'Never Added', inline: true }); + } + } + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = InviteInfo; diff --git a/src/commands/Misc/MemberCount.js b/src/commands/Misc/MemberCount.js new file mode 100644 index 0000000..30e4af7 --- /dev/null +++ b/src/commands/Misc/MemberCount.js @@ -0,0 +1,65 @@ +'use strict'; + +const { Command } = require('@dyno.gg/dyno-core'); + +class MemberCount extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['membercount']; + this.group = 'Misc'; + this.description = 'Get the server member count.'; + this.usage = 'membercount'; + this.cooldown = 10000; + this.expectedArgs = 0; + } + + async execute({ message, args }) { + const guild = message.channel.guild; + + if (args.length && (args.includes('full') || args.includes('withprune')) && this.isServerMod(message.member, message.channel)) { + try { + var pruneCount = await this.client.getPruneCount(guild.id, 30); + } catch (err) { + // pass + } + } + + if (args.length && (args.includes('full') || args.includes('withbans')) && this.isServerMod(message.member, message.channel)) { + try { + let bans = await this.client.getGuildBans(guild.id); + var banCount = bans.length; + } catch (err) { + // pass + } + } + + let fields = [ + { name: 'Members', value: guild.memberCount.toString(), inline: true }, + { name: 'Humans', value: guild.members.filter(m => !m.bot).length.toString(), inline: true }, + { name: 'Bots', value: guild.members.filter(m => m.bot).length.toString(), inline: true }, + ] + + if (this.config.isPremium) { + fields.push({ name: 'Online', value: guild.members.filter(m => m.status !== 'offline').length.toString(), inline: true }); + } + + if (pruneCount) { + fields.push({ name: 'Prune Count', value: pruneCount.toString(), inline: true }); + } + + if (banCount) { + fields.push({ name: 'Bans', value: banCount.toString(), inline: true }); + } + + const embed = { + color: this.utils.getColor('blue'), + fields: fields, + timestamp: new Date(), + }; + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = MemberCount; diff --git a/src/commands/Misc/RandomColor.js b/src/commands/Misc/RandomColor.js new file mode 100644 index 0000000..6f9bc59 --- /dev/null +++ b/src/commands/Misc/RandomColor.js @@ -0,0 +1,37 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class RandomColor extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['randomcolor', 'randcolor', 'randomcolour']; + this.group = 'Misc'; + this.description = 'Generates a random hex color with preview.'; + this.usage = 'randomcolor'; + this.cooldown = 3000; + this.expectedArgs = 0; + } + + execute({ message }) { + const int = (Math.random() * (1 << 24) | 0); + const hex = ('00000' + int.toString(16)).slice(-6); + const rgb = [(int & 0xff0000) >> 16, (int & 0x00ff00) >> 8, (int & 0x0000ff)]; + + const colorurl = `${this.config.colorapi.host}/color/${hex}/80x80.png`; + + return this.sendMessage(message.channel, { + embed: { + color: int, + fields: [ + { name: 'Hex', value: `#${hex}` }, + { name: 'RGB', value: `${rgb.join(', ')}` }, + ], + thumbnail: { url: colorurl }, + }, + }); + } +} + +module.exports = RandomColor; diff --git a/src/commands/Misc/ServerInfo.js b/src/commands/Misc/ServerInfo.js new file mode 100644 index 0000000..6265df4 --- /dev/null +++ b/src/commands/Misc/ServerInfo.js @@ -0,0 +1,68 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class ServerInfo extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['serverinfo']; + this.group = 'Misc'; + this.description = 'Get server info/stats.'; + this.usage = 'serverinfo'; + this.cooldown = 10000; + this.expectedArgs = 0; + } + + async execute({ message, args }) { + const guild = (this.isAdmin(message.author) && args && args.length) ? + this.client.guilds.get(args[0]) : message.channel.guild; + + const owner = this.client.users.get(guild.ownerID); + + let categories = guild.channels.filter(c => c.type === 4).length; + let textChannels = guild.channels.filter(c => c.type === 0).length; + let voiceChannels = guild.channels.filter(c => c.type === 2).length; + + const embed = { + color: (Math.random() * (1 << 24) | 0), + author: { + name: guild.name, + icon_url: guild.iconURL, + }, + thumbnail: { + url: `https://discordapp.com/api/guilds/${guild.id}/icons/${guild.icon}.jpg`, + }, + fields: [ + { name: 'Owner', value: this.utils.fullName(owner), inline: true }, + { name: 'Region', value: guild.region, inline: true }, + { name: 'Channel Categories', value: categories ? categories.toString() : '0', inline: true }, + { name: 'Text Channels', value: textChannels ? textChannels.toString() : '0', inline: true }, + { name: 'Voice Channels', value: voiceChannels ? voiceChannels.toString() : '0', inline: true }, + { name: 'Members', value: guild.memberCount.toString(), inline: true }, + // { name: 'Emojis', value: guild.emojis.length.toString(), inline: true }, + ], + footer: { + text: `ID: ${guild.id} | Server Created`, + }, + timestamp: new Date(guild.createdAt), + }; + + embed.fields.push({ name: 'Humans', value: guild.members.filter(m => !m.bot).length.toString(), inline: true }); + embed.fields.push({ name: 'Bots', value: guild.members.filter(m => m.bot).length.toString(), inline: true }); + + if (this.config.isPremium) { + embed.fields.push({ name: 'Online', value: guild.members.filter(m => m.status !== 'offline').length.toString(), inline: true }); + } + + embed.fields.push({ name: 'Roles', value: guild.roles.size.toString(), inline: true }); + + if (guild.roles.size < 25) { + embed.fields.push({ name: 'Role List', value: guild.roles.map(r => r.name).join(', '), inline: false }); + } + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = ServerInfo; diff --git a/src/commands/Misc/Whois.js b/src/commands/Misc/Whois.js new file mode 100644 index 0000000..3bf0e77 --- /dev/null +++ b/src/commands/Misc/Whois.js @@ -0,0 +1,128 @@ +'use strict'; + +const moment = require('moment'); +const {Command} = require('@dyno.gg/dyno-core'); + +class Whois extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['whois', 'userinfo']; + this.group = 'Misc'; + this.description = 'Get user information.'; + this.usage = 'whois [user mention]'; + this.example = 'whois @NoobLance'; + this.cooldown = 3000; + this.expectedArgs = 0; + } + + execute({ message, args }) { + let member = args.length ? this.resolveUser(message.channel.guild, args.join(' ')) : message.member; + + if (!member) return this.error(message.channel, `Couldn't find user ${args.join(' ')}`); + + const perms = { + administrator: 'Administrator', + manageGuild: 'Manage Server', + manageRoles: 'Manage Roles', + manageChannels: 'Manage Channels', + manageMessages: 'Manage Messages', + manageWebhooks: 'Manage Webhooks', + manageNicknames: 'Manage Nicknames', + manageEmojis: 'Manage Emojis', + kickMembers: 'Kick Members', + banMembers: 'Ban Members', + mentionEveryone: 'Mention Everyone', + }; + + const contrib = this.dyno.globalConfig.contributors.find(c => c.id === member.id); + const extra = []; + let team = []; + + const roles = member.roles && member.roles.length ? + this.utils.sortRoles(member.roles.map(r => { + r = message.channel.guild.roles.get(r); + + if (!r || !r.id) { + return 'Invalid role.'; + } + + return `<@&${r.id}>`; + })).join(' ') : 'None'; + + const joinPos = [...message.guild.members.values()] + .sort((a, b) => (a.joinedAt < b.joinedAt) ? -1 : ((a.joinedAt > b.joinedAt) ? 1 : 0)) + .filter(m => !m.bot) + .findIndex(m => m.id === member.id) + 1; + + const embed = { + author: { + name: this.utils.fullName(member), + icon_url: member.user.avatarURL, + }, + thumbnail: { + url: (contrib && contrib.badge) ? + `https://cdn.dyno.gg/badges/${contrib.badge}` : + member.user.avatarURL, + }, + description: `\n<@!${member.id}>`, + fields: [ + // { name: 'Status', value: member.status, inline: true }, + { name: 'Joined', value: moment.unix(member.joinedAt / 1000).format('llll'), inline: true }, + { name: 'Join Position', value: joinPos || 'None', inline: true }, + { name: 'Registered', value: moment.unix(member.user.createdAt / 1000).format('llll'), inline: true }, + { name: `Roles [${member.roles.length}]`, value: roles.length > 1024 ? `Too many roles to show.` : roles, inline: false }, + ], + footer: { text: `ID: ${member.id}` }, + timestamp: new Date(), + }; + + if (member.permission) { + const memberPerms = member.permission.json; + const infoPerms = []; + for (let key in memberPerms) { + if (!perms[key] || memberPerms[key] !== true) continue; + if (memberPerms[key]) { + infoPerms.push(perms[key]); + } + } + + if (infoPerms.length) { + embed.fields.push({ name: 'Key Permissions', value: infoPerms.join(', '), inline: false }); + } + } + + if (member.id === this.client.user.id) { + team.push('A Real Dyno'); + } + // if (this.isAdmin(member)) extra.push(`Dyno Creator`); + + if (contrib) { + team = team.concat(contrib.titles); + } + + if (this.isServerAdmin(member, message.channel)) { + if (member.id === message.channel.guild.ownerID) { + extra.push(`Server Owner`); + } else if (member.permission.has('administrator')) { + extra.push(`Server Admin`); + } else { + extra.push(`Server Manager`); + } + } else if (this.isServerMod(member, message.channel)) { + extra.push(`Server Moderator`); + } + + if (extra.length) { + embed.fields.push({ name: 'Acknowledgements', value: extra.join(', '), inline: false }); + } + + if (team.length) { + embed.fields.push({ name: 'Dyno Team', value: `${team.join(', ')}`, inline: false }); + } + + return this.sendMessage(message.channel, { embed }).catch(err => this.logger.error(err)); + } +} + +module.exports = Whois; diff --git a/src/commands/Roles/RoleInfo.js b/src/commands/Roles/RoleInfo.js new file mode 100644 index 0000000..2d8a963 --- /dev/null +++ b/src/commands/Roles/RoleInfo.js @@ -0,0 +1,60 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class RoleInfo extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['roleinfo']; + this.group = 'Roles'; + this.description = 'Get information about a role.'; + this.usage = 'roleinfo'; + this.expectedArgs = 1; + this.cooldown = 6000; + } + + execute({ message, args }) { + const guild = message.channel.guild; + const role = this.resolveRole(message.channel.guild, args.join(' ')); + + if (!role) { + return this.error(message.channel, `I can't find that role`); + } + + if (!guild.roles || !guild.roles.size) { + return this.error(message.channel, 'There are no roles on this server.'); + } + + let members = guild.members.filter(m => m.roles.includes(role.id)); + + const color = role.color ? ('00000' + role.color.toString(16)).slice(-6) : null; + + const embed = { + fields: [ + { name: 'ID', value: role.id, inline: true }, + { name: 'Name', value: role.name, inline: true }, + { name: 'Color', value: color ? `#${color}` : 'None', inline: true }, + { name: 'Mention', value: `\`<@&${role.id}>\``, inline: true }, + { name: 'Members', value: members.length.toString(), inline: true }, + { name: 'Hoisted', value: role.hoist ? 'Yes' : 'No', inline: true }, + { name: 'Position', value: role.position.toString(), inline: true }, + { name: 'Mentionable', value: role.mentionable ? 'Yes' : 'No', inline: true }, + ], + footer: { + text: `Role Created`, + }, + timestamp: new Date(role.createdAt), + }; + + if (color) { + const colorurl = `${this.config.colorapi.host}/color/${color}/80x80.png`; + embed.color = role.color; + embed.thumbnail = { url: colorurl }; + } + + return this.sendMessage(message.channel, { embed }); + } +} + +module.exports = RoleInfo; diff --git a/src/commands/Roles/Roles.js b/src/commands/Roles/Roles.js new file mode 100644 index 0000000..ce470a5 --- /dev/null +++ b/src/commands/Roles/Roles.js @@ -0,0 +1,64 @@ +'use strict'; + +const {Command} = require('@dyno.gg/dyno-core'); + +class Roles extends Command { + constructor(...args) { + super(...args); + + this.aliases = ['roles']; + this.group = 'Roles'; + this.description = 'Get a list of server roles and member counts.'; + this.usage = 'roles (optional search)'; + this.permissions = 'serverMod'; + this.expectedArgs = 0; + this.cooldown = 30000; + } + + async execute({ message, args }) { + try { + let query; + + if (args && args.length > 0) { + query = args.join(' ').toLowerCase(); + } + + const roles = await this.getRoles(message.channel.guild, query); + const msgArray = this.utils.splitMessage(roles, 1950); + + for (const m of msgArray) { + this.sendCode(message.channel, m); + } + + return Promise.resolve(); + } catch (err) { + return this.error(message.channel, 'Something went wrong.', err); + } + } + + getRoles(guild, query) { + if (!guild.roles || !guild.roles.size) { + return Promise.resolve('There are no roles on this server.'); + } + + let msgArray = [], + len = Math.max(...guild.roles.map(r => r.name.length)); + + let roles = this.utils.sortRoles(guild.roles); + + if (query) { + roles = roles.filter(r => r.name.toLowerCase().search(query) > -1); + } + + for (let role of roles) { + if (role.name === '@everyone') continue; + const members = guild.members.filter(m => m.roles.includes(role.id)); + role.memberCount = members && members.length ? members.length : 0; + msgArray.push(`${this.utils.pad(role.name, len)} ${role.memberCount} members`); + } + + return Promise.resolve(msgArray.join('\n')); + } +} + +module.exports = Roles; diff --git a/src/core/Dyno.js b/src/core/Dyno.js new file mode 100644 index 0000000..f823eaa --- /dev/null +++ b/src/core/Dyno.js @@ -0,0 +1,575 @@ +'use strict'; + +global.Promise = require('bluebird'); + +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); +const Eris = require('@dyno.gg/eris'); +const { utils } = require('@dyno.gg/dyno-core'); +const dot = require('dot-object'); +const each = require('async-each'); +const StatsD = require('hot-shots'); +const moment = require('moment'); +const uuid = require('uuid/v4'); +const config = require('./config'); +const logger = require('./logger'); +const redis = require('./redis'); +const db = require('./database'); +const PermissionsManager = require('./managers/PermissionsManager'); +const CommandCollection = require('./collections/CommandCollection'); +const ModuleCollection = require('./collections/ModuleCollection'); +const GuildCollection = require('./collections/GuildCollection'); +const WebhookManager = require('./managers/WebhookManager'); +const EventManager = require('./managers/EventManager'); +const IPCManager = require('./managers/IPCManager'); +const RPCServer = require('./RPCServer'); +const RPCClient = require('./RPCClient'); +const { Client } = require('./rpc'); +const prom = require('prom-client'); + + +var EventEmitter; + +try { + EventEmitter = require('eventemitter3'); +} catch (e) { + EventEmitter = require('events'); +} + +const redisLock = require('ioredis-lock'); + +var instance; + +const statsdClient = new StatsD({ + host: config.statsd.host, + port: config.statsd.port, + prefix: config.statsd.prefix, +}); + +const premiumWebhook = 'https://canary.discordapp.com/api/webhooks/523575952744120321/xrh6uyOA0MOuMvHDAZLw5qws-jr9cDELU6xOoXZSTZcLlwN7lMHxt6yQD-dqRmJuLnnB'; + +/** + * @class Dyno + */ +class Dyno { + + /** + * Dyno constructor + */ + constructor() { + this.isReady = false; + this.startTime = Date.now(); + this.uuid = uuid(); + + instance = this; // eslint-disable-line + + Object.defineProperty(Eris.Message.prototype, 'guild', { + get: function get() { return this.channel.guild; }, + }); + + process.on('unhandledRejection', this.handleRejection.bind(this)); + process.on('uncaughtException', this.crashReport.bind(this)); + + this.activityInterval = setInterval(this.uncacheGuilds.bind(this), 900000); + } + + static get instance() { + return instance; + } + + /** + * Eris client instance + * @returns {Eris} + */ + get client() { + return this._client; + } + + /** + * Eris rest client instance + * @return {Eris} + */ + get restClient() { + return this._restClient; + } + + /** + * Dyno configuration + * @returns {Object} + */ + get config() { + return config; + } + + /** + * Global configuration + * @return {Object} + */ + get globalConfig() { + return this._globalConfig; + } + + get logger() { + return logger; + } + + get db() { + return db; + } + + get models() { + return db.models; + } + + get redis() { + return this._redis; + } + + get statsd() { + return statsdClient; + } + + get utils() { + return utils; + } + + get prefix() { + return (config.prefix != undefined && typeof config.prefix === 'string') ? config.prefix : '?'; + } + + get prom() { + return prom; + } + + handleError(err) { + if (!err || (typeof err === 'string' && !err.length)) { + return logger.error('An undefined exception occurred.'); + } + + try { + logger.error(err); + } catch (e) { + console.error(err); // eslint-disable-line + } + } + + /** + * Unhandled rejection handler + * @param {Error|*} reason The reason the promise was rejected + * @param {Promise} p The promise that was rejected + */ + handleRejection(reason, p) { + try { + console.error('Unhandled rejection at: Promise ', p, 'reason: ', reason); // eslint-disable-line + } catch (err) { + console.error(reason); // eslint-disable-line + } + } + + crashReport(err) { + const cid = `C${this.clientOptions.clusterId}`; + const time = (new Date()).toISOString(); + let report = `Crash Report [${cid}] ${time}:\n\n${err.stack}`; + + report += `\n\nClient Options: ${JSON.stringify(this.clientOptions)}`; + + for (let module of this.modules.values()) { + if (module.crashReport) { + report += `\n\n${module.crashReport()}`; + } + } + + const file = path.join(__dirname, `crashreport_${cid}_${time}.txt`); + fs.writeFileSync(file, report); + + setTimeout(() => process.exit(), 6000); + } + + range(start, end) { + return (new Array(end - start + 1)).fill(undefined).map((_, i) => i + start); + } + + /** + * Setup Dyno and login + */ + async setup(options, rootContext) { + options = options || {}; + + await this.configure(options); + + options.restClient = { restMode: true }; + + this.options = Object.assign({}, { rootCtx: rootContext }, options); + this.clientOptions = options; + + this.shards = this.range(options.firstShardId, options.lastShardId); + + //connect to redis + try { + this._redis = await redis.connect(); + } catch (err) { + logger.error(err); + } + + const pipeline = this.redis.pipeline(); + + for (let shard of this.shards) { + pipeline.hgetall(`guild_activity:${config.client.id}:${options.shardCount}:${shard}`); + } + + let results = await pipeline.exec(); + + results = results.map(r => { + let [err, res] = r; + if (err) { + return; + } + return res; + }).filter(r => r != null); + + this._guildActivity = Object.assign(...results); + + // create the discord client + const token = this.config.isPremium ? config.client.token : this.globalConfig.prodToken || config.client.token; + + this._client = new Eris(token, config.clientOptions); + this._restClient = new Eris(`Bot ${token}`, options.restClient); + + this.client.on('error', err => logger.error(err)); + this.client.on('warn', err => logger.error(err)); + this.client.on('debug', msg => { + if (typeof msg === 'string') { + msg = msg.replace(config.client.token, 'potato'); + } + logger.debug(`[Eris] ${msg}`); + }); + + if (!options.awaitReady) { + this.client.once('shardReady', () => { + this.isReady = true; + this.user = this._client.user; + this.userid = this._client.user.id; + }); + } + + this.dispatcher = new EventManager(this); + this.ipc = new IPCManager(this); + this.internalEvents = new EventEmitter(); + + // Create collections + this.commands = new CommandCollection(config, this); + this.modules = new ModuleCollection(config, this); + this.guilds = new GuildCollection(config, this); + + // Create managers + this.webhooks = new WebhookManager(this); + this.permissions = new PermissionsManager(this); + + // Create RPC Server + this.rpcServer = new RPCServer(this); + this.RPCClient = RPCClient; + this.cmClient = new Client(config.rpcHost || 'localhost', 5052); + + // event listeners + this.client.once('ready', this.ready.bind(this)); + this.client.on('error', this.handleError.bind(this)); + + // login to discord + this.login(); + + this.readyTimeout = setTimeout(() => { + try { + this.ipc.send('ready'); + } catch (err) { + logger.error(`IPC Error Caught:`, err); + } + }, 90000); + } + + async configure(options) { + await this.loadConfig().catch(() => null); + this.watchGlobal(); + + const clientConfig = { + disableEvents: { + TYPING_START: true, + }, + disableEveryone: config.client.disableEveryone, + getAllUsers: config.client.fetchAllUsers || false, + firstShardID: options.firstShardId || options.clusterId || options.shardId || 0, + lastShardID: options.lastShardId || options.clusterId || options.shardId || 0, + maxShards: options.shardCount || 1, + messageLimit: parseInt(config.client.maxCachedMessages) || 10, + guildCreateTimeout: 2000, + largeThreshold: 50, + defaultImageFormat: 'png', + preIdentify: this.preIdentify.bind(this), + intents: config.client.intents || undefined, + }; + + if (!this.config.isPremium && !config.test) { + // if ((options.clusterId % 2) > 0) { + // clientConfig.compress = true; + // } + + if (!this.globalConfig.disableGuildActivity) { + clientConfig.disableEvents.PRESENCE_UPDATE = true; + clientConfig.createGuild = this.createGuild.bind(this); + } + } + + if (config.disableEvents) { + for (let event of config.disableEvents) { + clientConfig.disableEvents[event] = true; + } + } + + config.clientOptions = clientConfig; + + await this.loadConfig().catch(() => null); + await this.watchGlobal(); + + return clientConfig; + } + + async watchGlobal() { + await this.updateGlobal(); + + this._globalConfigInterval = setInterval(() => this.updateGlobal(), 2 * 60 * 1000); + } + + async loadConfig() { + try { + if (this.models.Config != undefined) { + const dbConfig = await this.models.Config.findOne({ clientId: config.client.id }).lean(); + if (dbConfig) { + config = Object.assign(config, dbConfig); + } + } + const globalConfig = await this.models.Dyno.findOne().lean(); + this._globalConfig = config.global = globalConfig; + } catch (err) { + this.logger.error(err); + } + } + + async updateGlobal() { + try { + const globalConfig = await this.models.Dyno.findOne().lean(); + if (globalConfig) { + this._globalConfig = config.global = globalConfig; + } + } catch (err) { + logger.error(err, 'globalConfigRefresh'); + } + } + + updateStatus(status) { + this.playingStatus = status; + this.client.editStatus('online', { name: this.playingStatus, type: 0 }); + } + + /** + * Login to Discord + * @returns {*} + */ + login() { + // connect to discord + this.client.connect(); + + return false; + } + + preIdentify(id) { + let bucket, key, timeout; + + if (config.isPremium && !config.test) { + timeout = 5250; + key = `shard:identify:${config.client.id}`; + } else { + bucket = id % 16; + timeout = 7500; + key = `shard:identify:${config.client.id}:${bucket}`; + } + + const lock = redisLock.createLock(this.redis, { + timeout: timeout, + retries: Number.MAX_SAFE_INTEGER, + delay: 50, + }); + + return new Promise((resolve, reject) => { + lock.acquire(key).then(() => { + if (id) { + logger.debug(`Acquired lock on ${id}`); + } + + return resolve(); + + // this.client.getBotGateway().then(data => { + // const sessionLimit = data.session_start_limit; + // if (sessionLimit.remaining <= 5) { + // this.alertSessionLimit(); + // return reject(`Session limit dangerously low.`); + // } + + // return resolve(); + // }); + }); + }); + } + + createGuild(_guild) { + let lastActive = this._guildActivity[_guild.id]; + + if (lastActive) { + lastActive = parseInt(lastActive, 10); + _guild.lastActive = lastActive; + let diff = (Date.now() - lastActive); + let min = (60 * 60 * 24 * 1 * 1000); // 1 days + + delete this._guildActivity[_guild.id]; + + if (diff > min) { + _guild.inactive = true; + return this.client.guilds.add(_guild, this.client, true); + } + } + + let guild = this.client.guilds.add(_guild, this.client, true); + + if (config.clientOptions.getAllUsers && guild.members.size < guild.memberCount) { + guild.fetchAllMembers(); + } + + return guild; + } + + uncacheGuilds() { + const guilds = this.client.guilds.filter(g => !g.inactive); + for (let guild of guilds) { + const diff = (Date.now() - guild.lastActive); + const min = (60 * 60 * 24 * 1 * 1000); // 1 days + + if (diff > min) { + let _guild = { + id: guild.id, + unavailable: guild.unavailable, + member_count: guild.memberCount, + lastActive: guild.lastActive, + inactive: true, + }; + this.client.guilds.add(_guild, this.client, true); + } + } + } + + alertSessionLimit() { + const lock = redisLock.createLock(redis, { + timeout: 60000, + retries: 0, + delay: 250, + }); + + lock.acquire(`alerts:session:${config.client.id}`).then(() => { + this.restClient.executeWebhook('482709011356057631', 'oLrn3NnEg2cL-7cM6mFrvgdTPoCMx-unh8k2YwlEIkcZnXeOy54-QKZpOMUkPZ527x7X', { + embeds: [{ + color: 15607824, + title: `Danger`, + description: `**Dyno is dangerously close to token reset. Some shards may be offline. Let someone know.**`, + timestamp: (new Date()).toISOString(), + }], + }).catch(() => null); + }).catch(() => null); + } + + /** + * Ready event handler + */ + ready() { + logger.info(`[Dyno] ${this.config.name} ready with ${this.client.guilds.size} guilds.`); + + // register discord event listeners + this.dispatcher.bindListeners(); + + clearTimeout(this.readyTimeout); + try { + this.ipc.send('ready'); + } catch (err) { + logger.error(`IPC Error Caught:`, err); + } + + this.user = this._client.user; + this.userid = this._client.user.id; + + this.isReady = true; + + if (this.globalConfig.playingStatus) { + this.playingStatus = this.globalConfig.playingStatus[this.config.client.id] || + this.globalConfig.playingStatus.default || + this.config.client.game; + this.client.editStatus('online', { name: this.playingStatus, type: 0 }); + } else if (this.config.client.game) { + this.client.editStatus('online', { name: this.config.client.game, type: 0 }); + } + + if (this.config.isPremium) { + this.leaveInterval = setInterval(this.leaveGuilds.bind(this), 300000); + this.leaveGuilds(); + } + } + + async leaveGuilds() { + try { + var docs = await this.models.Server.find({ deleted: false, isPremium: true }, { _id: 1, isPremium: 1, premiumInstalled: 1 }).lean().exec(); + } catch (err) { + return logger.error(err); + } + + each([...this.client.guilds.values()], guild => { + let guildConfig = docs.find(d => d._id === guild.id); + if (!guildConfig || !guildConfig.isPremium) { + this.verifyAndLeave(guild.id); +// this.guilds.update(guild.id, { $set: { premiumInstalled: false } }).catch(err => false); +// this.client.leaveGuild(guild.id); + } + }); + } + + async verifyAndLeave(guildId) { + try { + const doc = await this.models.Server.findOne({ _id: guildId }).lean(); + if (!doc) { + this.postWebhook(premiumWebhook, { embeds: [{ title: 'Premium Verification Failed', description: `No guild config was returned: ${guildId}`, color: 16729871 }] }); + return logger.error(`Premium verification failed: No guild config was returned. ${guildId}`); + } + + if (doc.isPremium) { + this.postWebhook(premiumWebhook, { embeds: [{ title: 'Premium Verification Failed', description: `Guild is premium but was scheduled to leave: ${guildId}`, color: 16729871 }] }); + return logger.error(`Premium verification failed: Guild is premium, but was flagged for deletion. ${guildId}`); + } + + this.postWebhook(premiumWebhook, { embeds: [{ title: 'Premium Verification Passed', description: `Leaving Guild ${guildId}` }], color: 2347360 }); + this.guilds.update(guildId, { $set: { premiumInstalled: false } }).catch(err => false); + this.client.leaveGuild(guildId); + } catch (err) { + logger.error(err); + } + } + + postWebhook(webhook, payload) { + return new Promise((resolve, reject) => + axios.post(webhook, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + ...payload, + }) + .then(resolve) + .catch(reject)); + } +} + +module.exports = Dyno; diff --git a/src/core/RPCClient.js b/src/core/RPCClient.js new file mode 100644 index 0000000..1ca26c1 --- /dev/null +++ b/src/core/RPCClient.js @@ -0,0 +1,15 @@ +const jayson = require('jayson'); + +class RPCClient { + constructor(dyno, host, port) { + this.dyno = dyno; + this.client = jayson.client.http({ + host, + port, + }); + + return this.client; + } +} + +module.exports = RPCClient; diff --git a/src/core/RPCServer.js b/src/core/RPCServer.js new file mode 100644 index 0000000..0b281f6 --- /dev/null +++ b/src/core/RPCServer.js @@ -0,0 +1,125 @@ +/* eslint-disable no-unused-vars */ +const { Server } = require('./rpc'); +const util = require('util'); +const Diagnostics = require('./utils/Diagnostics'); + +class RPCServer extends Server { + constructor(dyno) { + super(); + + this.dyno = dyno; + this.id = dyno.clientOptions.clusterId; + this.logger = dyno.logger; + + const host = dyno.config.rpcHost || 'localhost'; + const port = 30000 + parseInt(this.id, 10); + + let methods = { + rlreset: this.rlreset.bind(this), + debug: this.debug.bind(this), + diagnose: this.diagnose.bind(this), + }; + + // wrap methods for auth + for (let [key, fn] of Object.entries(methods)) { + methods[key] = this.auth.bind(this, fn); + } + + this.diagnostics = new Diagnostics(dyno); + + this.init(host, port, methods); + } + + auth(handler, payload, cb) { + if (payload) { + if (!payload.token || payload.token !== (this.dyno.config.rpcToken)) { + return cb(`Invalid token.`); + } + + this.logger.debug('[RPC] Auth Passed.'); + return handler(payload, cb); + } + } + + rlreset(payload, cb) { + try { + const guild = this.dyno.client.guilds.get(payload.id); + + this.logger.debug(`[RPC] Clearing guild level rate limits.`); + + Object.keys(this.dyno.client.requestHandler.ratelimits).filter(k => k.includes(guild.id)).forEach(k => { + this.dyno.client.requestHandler.ratelimits[k]._queue = []; + this.dyno.client.requestHandler.ratelimits[k].remaining = 1; + this.dyno.client.requestHandler.ratelimits[k].check(true); + delete this.dyno.client.requestHandler.ratelimits[k]; + }); + + this.logger.debug(`[RPC] Clearing channel level rate limits.`); + for (let channel of guild.channels.values()) { + Object.keys(this.dyno.client.requestHandler.ratelimits).filter(k => k.includes(channel.id)).forEach(k => { + this.dyno.client.requestHandler.ratelimits[k]._queue = []; + this.dyno.client.requestHandler.ratelimits[k].remaining = 1; + this.dyno.client.requestHandler.ratelimits[k].check(true); + delete this.dyno.client.requestHandler.ratelimits[k]; + }); + } + + return cb(null, 'Success!'); + } catch (err) { + return cb(err); + } + } + + async debug(payload, cb) { + let dyno = this.dyno, + client = dyno.client, + config = dyno.config, + models = dyno.models, + redis = dyno.redis, + utils = dyno.utils, + result; + + try { + result = eval(payload.code); + } catch (e) { + result = e; + } + + try { + if (result && result.then) { + try { + result = await result; + } catch (err) { + result = err; + } + } + + if (!result) { + return cb(); + } + + result = result.toString(); + + return cb(null, result); + } catch (err) { + dyno.logger.error(err); + return cb(err); + } + } + + async diagnose(payload, cb) { + if (!payload.id || !payload.name) { + return cb('Invalid arguments.'); + } + + try { + const result = await this.diagnostics.diagnose(payload.id, payload.name); + return cb(null, result); + } catch (err) { + this.dyno.logger.error(err); + return cb(err); + } + } +} + +module.exports = RPCServer; diff --git a/src/core/cluster/Cluster.js b/src/core/cluster/Cluster.js new file mode 100644 index 0000000..55d0d4d --- /dev/null +++ b/src/core/cluster/Cluster.js @@ -0,0 +1,145 @@ +'use strict'; + +const cluster = require('cluster'); + +var EventEmitter; + +try { + EventEmitter = require('eventemitter3'); +} catch (e) { + EventEmitter = require('events'); +} + +/** + * @class Cluster + * @extends {EventEmitter} + */ +class Cluster extends EventEmitter { + /** + * Representation of a cluster + * + * @param {Number} id Cluster ID + * @prop {Number} [options.shardCount] Shard count + * @prop {Number} [options.firstShardId] Optional first shard ID + * @prop {Number} [options.lastShardId] Optional last shard ID + * @prop {Number} [options.clusterCount] Optional cluster count + * + * @prop {Number} id Cluster ID + * @prop {Object} worker The worker + * @prop {Object} process The worker process + * @prop {Number} pid The worker process ID + */ + constructor(manager, options) { + super(); + + this.id = options.id; + this.options = options; + this.shardCount = options.shardCount; + this.firstShardId = options.firstShardId; + this.lastShardId = options.lastShardId; + this.clusterCount = options.clusterCount; + + this.worker = this.createWorker(); + this.process = this.worker.process; + this.pid = this.process.pid; + } + + /** + * Create a cluster worker + * @return {Object} The worker process reference + */ + createWorker(awaitReady = false) { + const worker = cluster.fork( + Object.assign({ + awaitReady: awaitReady, + clusterId: this.id, + }, this.options) + ); + + this._pid = worker.process.pid; + + process.nextTick(() => { + this._readyListener = this.ready.bind(this); + this._shardReadyListener = this.shardReady.bind(this); + + worker.on('message', this._readyListener); + worker.on('message', this._shardReadyListener); + }); + + return worker; + } + + /** + * Restart a cluster worker + */ + restartWorker(awaitReady = false) { + const worker = this.createWorker(awaitReady); + const oldWorker = this.worker; + this._pid = worker.process.pid; + + return new Promise(resolve => { + this.on('ready', () => { + if (this.worker) { + oldWorker.kill('SIGTERM'); + } + + process.nextTick(() => { + this.worker = worker; + this.process = worker.process; + this.pid = worker.pid; + // this.worker.removeListener('ready', this._readyListener); + this.worker.removeListener('shardReady', this._shardReadyListener); + + return resolve(); + }); + }); + }); + } + + /** + * Listen for cluster ready event + * @param {String|Object} message Message received from the worker + */ + ready(message) { + if (!message || !message.op) return; + if (message.op === 'ready') { + this.emit('ready'); + } + } + + /** + * Listen for shard ready event + * @param {String|Object} message Message received from the worker + */ + shardReady(message) { + if (!message || !message.op) return; + if (message.op === 'shardReady') { + this.emit('shardReady', message.d); + } + } + + /** + * Send a command to the shard and await a response + * @param {Object} message The message to send + * @returns {Promise} + */ + awaitResponse(message) { + return new Promise((resolve) => { + const awaitListener = (msg) => { + if (!['resp', 'error'].includes(msg.op)) return; + this.worker.removeListener('message', awaitListener); + return resolve({ id: this.id, result: msg.d }); + }; + + this.worker.on('message', awaitListener); + this.worker.send(message); + + setTimeout(() => { + this.worker.removeListener('message', awaitListener); + return resolve({ id: this.id, error: 'IPC request timed out.' }); + }, 2000); + }); + } +} + +module.exports = Cluster; diff --git a/src/core/cluster/Events.js b/src/core/cluster/Events.js new file mode 100644 index 0000000..1580e6a --- /dev/null +++ b/src/core/cluster/Events.js @@ -0,0 +1,194 @@ +'use strict'; + +const cluster = require('cluster'); +const logger = require('../logger'); + +/** + * @class Events + */ +class Events { + /** + * Events manager + * @param {Manager} manager Cluster Manager instance + */ + constructor(manager) { + this.manager = manager; + this.logger = manager.logger; + this.clusters = manager.clusters; + + this.readyListener = this.onReady.bind(this); + this.messageListener = this.onMessage.bind(this); + this.exitHandler = this.manager.handleExit.bind(this.manager); + } + + /** + * Remove event listeners on module unload + */ + unload() { + cluster.removeListener('exit', this.exitHandler); + cluster.removeListener('online', this.readyListener); + cluster.removeListener('message', this.messageListener); + } + + /** + * Create event listeners when modul is loaded + */ + register() { + cluster.on('exit', this.exitHandler); + cluster.on('online', this.readyListener); + cluster.on('message', this.messageListener); + } + + /** + * Fired when a worker goes online + * @param {Object} worker Worker process + */ + onReady(worker) { + const cluster = this.manager.getCluster(worker); + const meta = cluster.firstShardId ? `Shards ${cluster.firstShardId}-${cluster.lastShardId}` : `Shard ${cluster.id}`; + logger.info(`[Events] Cluster ${cluster.id} online | ${meta}`); + } + + /** + * Fired when the cluster receives a message + * @param {Object} worker Worker process + * @param {Object} message The message object + * @returns {void} + */ + onMessage(worker, message) { + if (!message.op) return; + + // ignore responses + if (message.op === 'resp') return; + + if (this[message.op]) { + this[message.op](message); + } else if (message.op !== 'ready') { + this.awaitResponse(worker, message); + } + } + + /** + * Send a command to and await a response from the cluster + * @param {Object} worker Worker process + * @param {Object} message The message to send + * @returns {void} + */ + awaitResponse(worker, message) { + const promises = []; + + for (const cluster of this.clusters.values()) { + if (!cluster.worker || !cluster.worker.isConnected()) continue; + promises.push(cluster.awaitResponse(message)); + } + + return new Promise((resolve, reject) => { + Promise.all(promises).then(results => { + if (worker != null && worker.send) { + try { + worker.send({ op: 'resp', d: results }); + } catch (err) { + logger.error(err); + } + } + return resolve(results); + }).catch(err => { + if (worker != null && worker.send) { + try { + worker.send({ op: 'error', d: err }); + } catch (err) { + logger.error(err); + } + } + return reject(err); + }); + }); + } + + /** + * Send a command to a cluster + * @param {Number} clusterId Cluster ID + * @param {String|Object} message Message to send + * @return {Boolean} + */ + send(clusterId, message) { + const cluster = this.clusters.get(clusterId); + if (!cluster) { + logger.warn(`[Events] Cluster ${clusterId} not found attempting to send.`); + return; + } + if (!cluster.worker) { + logger.warn(`[Events] Cluster ${clusterId} worker not connected.`); + return; + } + + cluster.worker.send(message); + return true; + } + + /** + * Broadcast a message to all clusters + * @param {Object} message The message to send + */ + broadcast(message) { + if (message.op === 'broadcast') { + message = message.d; + } + + for (const cluster of this.clusters.values()) { + if (!cluster.worker || !cluster.worker.isConnected()) { + logger.warn(`[Events] Cluster ${cluster.id} worker not connected.`); + continue; + } + cluster.worker.send(message); + } + } + + /** + * Restart a cluster or clusters sequentially + * @param {Object} message The message received + * @returns {*} + */ + async restart(message) { + if (message.d !== undefined && message.d !== null && !isNaN(message.d)) { + const cluster = this.clusters.get(parseInt(message.d)); + if (!cluster) return; + + return cluster.restartWorker(true); + } else { + for (const cluster of this.clusters.values()) { + cluster.restartWorker(true); + await this.manager.awaitReady(cluster); + } + } + } + + blocked(message) { + this.logger.blocked.push(message.d); + } + + shardDisconnect(message) { + let msg = `[Events] Shard ${message.d.id} disconnected.`; + if (message.d.err) { + msg += ` ${message.d.err}`; + } + this.logger.shardStatus.push(msg); + } + + shardReady(message) { + let msg = `[Events] Shard ${message.d} ready.`; + this.logger.shardStatus.push(msg); + } + + shardResume(message) { + let msg = `[Events] Shard ${message.d} resumed.`; + this.logger.shardStatus.push(msg); + } + + shardIdentify(message) { + let msg = `[Events] Shard ${message.d} identified.`; + this.logger.shardStatus.push(msg); + } +} + +module.exports = Events; diff --git a/src/core/cluster/Logger.js b/src/core/cluster/Logger.js new file mode 100644 index 0000000..665f7dc --- /dev/null +++ b/src/core/cluster/Logger.js @@ -0,0 +1,129 @@ +'use strict'; + +const axios = require('axios'); +const config = require('../config'); +const logger = require('../logger'); + +/** + * @class Logger + */ +class Logger { + constructor(manager) { + this._postBlockedInterval = null; + this._postStatusInterval = null; + + this.blocked = []; + this.shardStatus = []; + + this.logger = manager.logger; + } + + register() { + this._postBlockedInterval = setInterval(() => { + if (!this.blocked || !this.blocked.length) return; + + this.log('Event Loops Blocked', null, { + webhookUrl: config.shardWebhook, + username: 'Shard Manager', + text: this.blocked.join('\n'), + suppress: true, + }); + + this.blocked = []; + }, 5000); + + this._postStatusInterval = setInterval(() => { + if (!this.shardStatus || !this.shardStatus.length) return; + + this.log('Shard Status Updates', null, { + webhookUrl: config.shardWebhook, + username: 'Shard Manager', + text: this.shardStatus.join('\n'), + suppress: true, + }); + + this.shardStatus = []; + }, 14000); + } + + unload() { + if (this._postBlockedInterval) { + clearInterval(this._postBlockedInterval); + } + + if (this._postStatusInterval) { + clearInterval(this._postStatusInterval); + } + } + + /** + * Log cluster status to console and discord + * @param {String} text Text to log + * @param {Array} [fields] Array of field objects for embed + * @param {Object} [options] An options object + */ + log(title, fields, options) { + if (!options || !options.suppress) { + logger.info(title); + } + + // if (config.state === 2) return; + if (!config.cluster) return; + + options = options || {}; + + const webhookUrl = options.webhookUrl || config.cluster.webhookUrl, + username = options.username || 'Cluster Manager'; + + const payload = { + username: username, + avatar_url: `${config.avatar}`, + embeds: [], + tts: false, + }; + + const embed = { + title: title, + timestamp: new Date(), + footer: { + text: config.stateName, + }, + }; + + if (options.text) { + embed.description = options.text; + } + + if (fields) embed.fields = fields; + + payload.embeds.push(embed); + + this.postWebhook(webhookUrl, payload) + .catch(err => logger.error(err)); // eslint-disable-line + } + + info(...args) { + logger.info(...args); + } + + /** + * Post to a discord webhook + * @param {String} webhook The webhook to post to + * @param {Object} payload The json payload to send + * @return {Promise} + */ + postWebhook(webhook, payload) { + return new Promise((resolve, reject) => + axios.post(webhook, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + ...payload + }) + .then(resolve) + .catch(reject)); + } +} + +module.exports = Logger; diff --git a/src/core/cluster/Manager.js b/src/core/cluster/Manager.js new file mode 100644 index 0000000..9448838 --- /dev/null +++ b/src/core/cluster/Manager.js @@ -0,0 +1,185 @@ +'use strict'; + +const os = require('os'); +const Cluster = require('./Cluster'); +const Events = require('./Events'); +const Logger = require('./Logger'); +const Sharding = require('./Sharding'); +const Server = require('./Server'); +const config = require('../config'); +const { Collection } = require('@dyno.gg/dyno-core'); + +/** + * @class Manager + */ +class Manager { + /** + * Create the cluster manager + * @param {String} strategy Sharding strategy + */ + constructor(strategy) { + this.clusters = new Collection(); + this.queue = []; + + this.shardCount = config.shardCountOverride || os.cpus().length; + + process.on('uncaughtException', this.handleException.bind(this)); + process.on('unhandledRejection', this.handleRejection.bind(this)); + + this.logger = new Logger(this); + this.events = new Events(this); + this.sharding = new Sharding(this); + this.server = new Server(this); + + strategy = strategy || config.shardingStrategy; + + this.logger.register(); + this.logger.info(`[Manager] Sharding strategy ${strategy}`); + + if (strategy && this.sharding[strategy]) { + this.sharding[strategy](); + } else { + this.sharding.createShardsProcess(); + } + } + + /** + * Unhandled rejection handler + * @param {Error|*} reason The reason the promise was rejected + * @param {Promise} p The promise that was rejected + */ + handleRejection(reason, p) { + try { + console.error('Unhandled rejection at: Promise ', p, 'reason: ', reason); // eslint-disable-line + } catch (err) { + console.error(reason); // eslint-disable-line + } + } + + handleException(err) { + if (!err || (typeof err === 'string' && !err.length)) { + return logger.error('An undefined exception occurred.'); + } + + try { + logger.error(err); + } catch (e) { + console.error(err); // eslint-disable-line + } + } + + /** + * Reload a module + * @param {String} module Module name + */ + reloadModule(module) { + const modulekey = module.toLowerCase(); + const activeModule = this[modulekey]; + if (!activeModule) return; + + if (activeModule.unload) { + activeModule.unload(); + } + + this[modulekey] = requireReload(require)(`./${module}`); + + if (this[modulekey].register) { + this[modulekey].register(); + } + } + + /** + * Create a cluster + * @param {Number} id Shard ID + */ + createCluster(options) { + const cluster = new Cluster(this, options); + this.clusters.set(parseInt(cluster.id), cluster); + + return cluster; + // return this.awaitReady(cluster); + } + + /** + * Queue a cluster for restart + * @param {Cluster} cluster The cluster to queue + */ + queueCluster(cluster) { + this.queue.push(cluster); + + if (this.queue.length === 1) { + this.processQueue(); + } + } + + /** + * Process the restart queue + */ + processQueue() { + const cluster = this.queue[0]; + + process.nextTick(() => { + this.logger.log(`Cluster ${cluster.id} restarting...`); + }); + + cluster.restartWorker().then(() => { + this.queue.shift(); + this.logger.log(`Cluster ${cluster.id} ready.`); + if (this.queue.length > 0) { + this.processQueue(); + } + }); + } + + /** + * Await the ready event from a cluster + * @param {Shard} cluster The cluster to wait + * @return {Promise} + */ + awaitReady(cluster) { + return new Promise(resolve => + cluster.on('ready', resolve)); + } + + /** + * Get a cluster by worker + * @param {Object} worker Worker process + * @returns {Shard} A cluster matching the worker pid + */ + getCluster(worker) { + return this.clusters.find(s => s.pid === worker.process.pid || s._pid === worker.process.pid); + } + + /** + * Handle a cluster dying + * @param {Object} worker Worker process + */ + handleExit(worker, code, signal) { + const cluster = this.getCluster(worker); + + if (signal && signal === 'SIGTERM') return; + if (!cluster) return; + + const meta = cluster.firstShardId !== null ? `${cluster.firstShardId}-${cluster.lastShardId}` : cluster.id.toString(); + + this.logger.log(`Cluster ${cluster.id} died with code ${signal || code}, restarting...`, [ + { name: 'Shards', value: meta }, + ]); + + // process.nextTick(() => { + // this.logger.log(`Cluster ${cluster.id} restarting...`); + // }); + + cluster.restartWorker().then(() => { + this.queue.shift(); + this.logger.log(`Cluster ${cluster.id} ready.`); + if (this.queue.length > 0) { + this.processQueue(); + } + }); + + // this.queueCluster(cluster); + } +} + +module.exports = Manager; diff --git a/src/core/cluster/Server.js b/src/core/cluster/Server.js new file mode 100644 index 0000000..f8d4570 --- /dev/null +++ b/src/core/cluster/Server.js @@ -0,0 +1,164 @@ +'use strict'; + +const http = require('http'); +const config = require('../config'); +const { models } = require('../database'); +const logger = require('../logger'); + +/** + * @class Server + */ +class Server { + /** + * HTTP Server + * @param {Manager} manager Cluster Manager instance + */ + constructor(manager) { + this.manager = manager; + this.events = manager.events; + + this.sockets = {}; + this.nextSocketId = 0; + + this.server = http.createServer(this.handleRequest.bind(this)) + .listen(5000); + + this.server.on('connection', socket => { + let socketId = this.nextSocketId++; + this.sockets[socketId] = socket; + socket.on('close', () => { + delete this.sockets[socketId]; + }); + }); + } + + unload() { + this.server.close(); + for (var socketId in this.sockets) { + this.sockets[socketId].destroy(); + } + } + + handleRequest(req, res) { + const parts = req.url.split('/'); + const handler = parts[1]; + + let getKey = `get${ucfirst(handler)}`, + postKey = `post${ucfirst(handler)}`, + key; + + if (req.method === 'GET' && this[getKey]) { + key = getKey; + } else if (req.method === 'POST' && this[postKey]) { + key = postKey; + } else { + return this.end(res, 404, 'Not Found'); + } + + let body = ''; + req.on('data', data => { + body += data; + }); + + req.on('end', () => { + const payload = { + req, res, body, + path: parts.slice(2), + }; + + this[key](payload); + }); + } + + end(res, status, body) { + if (typeof body === 'object' || Array.isArray(body)) { + body = JSON.stringify(body); + } + + res.writeHead(status); + res.write(body); + return res.end(); + } + + getPing({ res }) { + return this.end(res, 200, 'Pong!'); + } + + getShards({ res }) { + return this.events.awaitResponse(null, { op: 'shards' }) + .then(data => this.end(res, 200, data)) + .catch(err => this.end(res, 500, err)); + } + + async postRestart({ res, body }) { + if (!body) return this.end(res, 500, 'Invalid request 0'); + + try { + body = JSON.parse(body); + } catch (err) { + return this.end(res, 500, err); + } + + if (!body.token || body.id == undefined) return this.end(res, 500, 'Invalid request 1'); + + const restartToken = config.restartToken; + if (body.token !== restartToken) return this.end(res, 403, 'Forbidden'); + + if (body.id === 'all') { + for (const cluster of this.manager.clusters.values()) { + cluster.restartWorker(true); + await this.manager.awaitReady(cluster); + } + + return this.end(res, 200, 'OK'); + } + + const cluster = this.manager.clusters.get(parseInt(body.id)); + if (!cluster) return this.end(res, 404, 'Cluster not found'); + cluster.restartWorker(true); + return this.end(res, 200, 'OK'); + } + + postPing({ res }) { + return this.events.awaitResponse(null, { op: 'ping' }) + .then(data => this.end(res, 200, data)) + .catch(err => this.end(res, 500, err)); + } + + postGuildUpdate({ res, body }) { + if (!body) return this.end(res, 500, 'Invalid request'); + + this.events.broadcast({ op: 'guildUpdate', d: body }); + + return this.end(res, 200, 'OK'); + } + + postStats({ res, body }) { + if (!body) return this.end(res, 500, 'Invalid request'); + + this.events.send({ op: 'postStats', d: body }); + + return this.end(res, 200, 'OK'); + } + + postReload({ res, body }) { + if (!body) return this.end(res, 500, 'Invalid request'); + + try { + body = JSON.parse(body); + } catch (err) { + logger.error(err); + return this.end(res, 500, 'Internal error'); + } + + if (!body.c) return this.end(res, 500, 'Invalid request'); + this.manager.reloadModule(body.c); + return this.end(res, 200, 'OK'); + } +} + +function ucfirst(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +module.exports = Server; diff --git a/src/core/cluster/Sharding.js b/src/core/cluster/Sharding.js new file mode 100644 index 0000000..8ba5042 --- /dev/null +++ b/src/core/cluster/Sharding.js @@ -0,0 +1,226 @@ +'use strict'; + +const os = require('os'); +const Eris = require('@dyno.gg/eris'); +const config = require('../config'); +const logger = require('../logger'); + +/** + * @class Sharding + */ +class Sharding { + /** + * Sharding manager + * @param {Manager} manager Cluster Manager instance + */ + constructor(manager) { + this.manager = manager; + this.logger = manager.logger; + this.shardCount = os.cpus().length; + } + + /** + * Alias for process strategy + */ + createShardsProcess() { + return this.process(); + } + + /** + * Alias for shared strategy + */ + createShardsShared() { + return this.shared(); + } + + /** + * Alias for balanced strategy + */ + createShardsBalancedCores() { + return this.balanced(); + } + + /** + * Alias for semibalanced strategy + */ + createShardsSemiBalanced() { + return this.semibalanced(); + } + + /** + * Create clusters sequentially + */ + async process() { + const shardCount = config.shardCountOverride || await this.getShardCount(); + + const shardIds = config.shardIds || []; + + this.shardCount = shardCount; + this.manager.events.register(); + + this.logger.log(`[Sharding] Starting with ${shardIds.length || shardCount} shards.`); + + for (let i = 0; i < shardCount; i++) { + if (shardIds.length && !shardIds.includes(i.toString())) continue; + + this.manager.createCluster({ + id: i, + shardCount, + }); + await new Promise(res => setTimeout(res, 6500)); + } + } + + /** + * Create a shared state instance + */ + async shared() { + const shardCount = config.shardCountOverride || await this.getShardCount(); + + this.shardCount = shardCount; + this.manager.events.register(); + this.logger.log(`[Sharding] Starting with ${shardCount} shards.`); + + this.manager.createCluster({ + id: 0, + clusterCount: 1, + shardCount: shardCount, + firstShardId: 0, + lastShardId: shardCount - 1, + }); + } + + chunkArray(arr, chunkCount) { + const arrLength = arr.length; + const tempArray = []; + let chunk = []; + + const chunkSize = Math.floor(arr.length / chunkCount); + let mod = arr.length % chunkCount; + let tempChunkSize = chunkSize; + + for (let i = 0; i < arrLength; i += tempChunkSize) { + tempChunkSize = chunkSize; + if (mod > 0) { + tempChunkSize = chunkSize + 1; + mod--; + } + chunk = arr.slice(i, i + tempChunkSize); + tempArray.push(chunk); + } + + return tempArray; + } + + /** + * Create shards balanced across all cores + * @param {Boolean|undefined} semi If false, round up to a multiple of core count + */ + async balanced(semi) { + const shardCount = config.shardCountOverride || await this.getShardCount(semi); + const len = config.clusterCount || os.cpus().length; + + let firstShardId = config.firstShardOverride || 0, + lastShardId = config.lastShardOverride || (shardCount - 1); + + const localShardCount = config.shardCountOverride ? (lastShardId + 1) - firstShardId : shardCount; + + const shardIds = [...Array(1 + lastShardId - firstShardId).keys()].map(v => firstShardId + v); + // const clusterShardCount = Math.ceil(shardIds.length / len); + const shardCounts = this.chunkArray(shardIds, len); + + this.shardCount = shardCount; + + this.manager.events.register(); + this.logger.log(`[Sharding] Starting with ${localShardCount} shards in ${len} clusters.`); + + const clusterIds = config.clusterIds || []; + + for (let i in shardCounts) { + const count = shardCounts[i].length; + lastShardId = (firstShardId + count) - 1; + + if (clusterIds.length && !clusterIds.includes(i.toString())) { + firstShardId += count; + continue; + } + + await this.manager.createCluster({ + id: i.toString(), + clusterCount: len.toString(), + shardCount: shardCount.toString(), + firstShardId: firstShardId.toString(), + lastShardId: lastShardId.toString(), + }); + + firstShardId += count; + } + } + + /** + * Create shards semi-balanced across all ores + */ + async semibalanced() { + return this.balanced(true); + } + + /** + * Get estimated guild count + */ + async getEstimatedGuilds() { + const client = new Eris(config.client.token); + + try { + var data = await client.getBotGateway(); + } catch (err) { + return Promise.resolve(); + } + + if (!data || !data.shards) return Promise.resolve(); + + logger.info(`[Sharding] Discord suggested ${data.shards} shards.`); + return Promise.resolve(parseInt(data.shards) * 1000); + } + + /** + * Fetch guild count with fallbacks in the event of an error + * @return {Number} Guild count + */ + async fetchGuildCount() { + let res, guildCount; + guildCount = await this.getEstimatedGuilds(); + return guildCount; + } + + /** + * Get shard count to start + * @param {Boolean} balanced Whether or not to round up + * @return {Number} Shard count + */ + async getShardCount(balanced) { + try { + var guildCount = await this.fetchGuildCount(); + } catch (err) { + throw new Error(err); + } + + if (!guildCount || isNaN(guildCount)) { + throw new Error('Unable to get guild count.'); + } + + guildCount = parseInt(guildCount); + + logger.debug(`${guildCount} Guilds`); + + if (guildCount < 2500) { + guildCount = 2500; + } + + let n = balanced ? os.cpus().length : 2; + + const shardCalc = Math.round((Math.ceil(guildCount / 2500) * 2500) / 1400); + return Math.max(this.shardCount, n * Math.ceil(shardCalc / n)); + } +} + +module.exports = Sharding; diff --git a/src/core/clusterManager/Commands.js b/src/core/clusterManager/Commands.js new file mode 100644 index 0000000..9266b71 --- /dev/null +++ b/src/core/clusterManager/Commands.js @@ -0,0 +1,161 @@ +const config = require('../config'); +const db = require('../database'); +const { Client } = require('../rpc'); + +class Commands { + constructor(manager) { + this.manager = manager; + this.logger = manager.logger; + this.pmClient = manager.pmClient; + this.restClient = manager.restClient; + + return { + blocked: this.blocked.bind(this), + processExit: this.processExit.bind(this), + processReady: this.processReady.bind(this), + createCluster: this.createCluster.bind(this), + moveCluster: this.moveCluster.bind(this), + shardDisconnect: this.shardDisconnect.bind(this), + shardReady: this.shardReady.bind(this), + shardResume: this.shardResume.bind(this), + restart: this.restart.bind(this), + } + } + + async processReady({ process }, cb) { + if (process.port) { + process.client = new Client(config.rpcHost || 'localhost', process.port); + } + this.manager.processes.set(process.id, process); + + const cluster = process.options; + + if (cluster && cluster.id) { + this.logger.log(`[${process.pid}] Cluster ${cluster.id} ready`); + } + + return cb(null); + } + + processExit({ process, code, signal }, cb) { + const cluster = process.options; + + if (cluster && cluster.id) { + const meta = cluster.firstShardId !== null ? `${cluster.firstShardId}-${cluster.lastShardId}` : cluster.id.toString(); + + this.logger.log(`Cluster ${cluster.id} died with code ${signal || code}`, [ + { name: 'Shards', value: meta }, + ]); + } + + return cb(null); + } + + async restart({ id, token }, cb) { + if (!token || id == undefined) { + return cb('Invalid request'); + } + + const restartToken = config.restartToken; + if (token !== restartToken) { + return cb('Invalid token'); + } + + try { + if (id === 'all') { + for (let proc of this.manager.processes.values()) { + await this.manager.restartProcess(proc); + } + + return cb(null); + } + + const proc = this.manager.processes.find(p => p.options.id === parseInt(id, 10)); + if (!proc) { + return cb(`Unable to find cluster ${id}`); + } + + this.logger.log(`[${proc.pid}] Cluster ${id} restarting`); + await this.manager.restartProcess(proc); + return cb(null); + } catch (err) { + this.logger.error(err); + return cb(err); + } + } + + async createCluster({ id }, cb) { + try { + let cluster = this.manager.clusters.get(id); + if (!cluster) { + const coll = db.collection('clusters'); + cluster = await coll.findOne({ 'host.state': config.state, id }); + this.manager.clusters.set(id, cluster); + } + await this.manager.createProcess(cluster); + return cb(null); + } catch (err) { + this.logger.error(err); + return cb(err); + } + } + + async moveCluster({ id, name, token }, cb) { + if (!token || id == undefined) { + return cb('Invalid request'); + } + + const restartToken = config.restartToken; + if (token !== restartToken) { + return cb('Invalid token'); + } + + try { + const cluster = this.manager.clusters.get(id); + const host = await db.collection('hosts').findOne({ name }); + if (!host) { + return cb('Inavalid host.'); + } + + await db.collection('clusters').updateOne({ 'host.state': config.state, id }, { $set: { host: host } }); + + const client = new Client(host.hostname, 5052); + + await client.request('createCluster', { id }); + await this.manager.deleteProcess(cluster); + + return cb(null); + } catch (err) { + this.logger.error(err); + return cb(err); + } + } + + blocked({ text }, cb) { + this.logger.blocked.push(text); + return cb(null); + } + + shardDisconnect({ id, cluster, err }, cb) { + let msg = `[C${cluster}] Shard ${id} disconnected`; + if (err) { + msg += ` ${err}`; + } + this.logger.shardStatus.push(msg); + return cb(null); + } + + shardReady({ id, cluster }, cb) { + let msg = `[C${cluster}] Shard ${id} ready`; + this.logger.shardStatus.push(msg); + return cb(null); + } + + shardResume({ id, cluster }, cb) { + let msg = `[C${cluster}] Shard ${id} resumed`; + this.logger.shardStatus.push(msg); + return cb(null); + } +} + +module.exports = Commands; diff --git a/src/core/clusterManager/Logger.js b/src/core/clusterManager/Logger.js new file mode 100644 index 0000000..5604c3d --- /dev/null +++ b/src/core/clusterManager/Logger.js @@ -0,0 +1,121 @@ +const config = require('../config'); +const logger = require('../logger'); +const { utils } = require('@dyno.gg/dyno-core'); + +/** + * @class Logger + */ +class Logger { + constructor(manager) { + this._postBlockedInterval = null; + this._postStatusInterval = null; + + this.blocked = []; + this.shardStatus = []; + + this.manager = manager; + this.client = manager.restClient; + + this.register(); + } + + register() { + this._postBlockedInterval = setInterval(() => { + if (!this.blocked || !this.blocked.length) return; + + this.log('Event Loops Blocked', null, { + webhookUrl: config.shardWebhook, + username: 'Shard Manager', + text: this.blocked.join('\n'), + suppress: true, + }); + + this.blocked = []; + }, 6000); + + this._postStatusInterval = setInterval(() => { + if (!this.shardStatus || !this.shardStatus.length) return; + + let msgArray = []; + msgArray = msgArray.concat(utils.splitMessage(this.shardStatus, 1900)); + + for (let msg of msgArray) { + this.log('Shard Status Updates', null, { + webhookUrl: config.shardWebhook, + username: 'Shard Manager', + text: msg, + suppress: true, + }); + } + + this.shardStatus = []; + }, 5500); + } + + /** + * Log cluster status to console and discord + * @param {String} text Text to log + * @param {Array} [fields] Array of field objects for embed + * @param {Object} [options] An options object + */ + log(title, fields, options) { + if (!options || !options.suppress) { + logger.info(title); + } + + // if (config.state === 2) return; + if (!config.cluster) return; + + options = options || {}; + + const webhookUrl = options.webhookUrl || config.cluster.webhookUrl; + const username = options.username || 'Cluster Manager'; + + const payload = { + username: username, + avatar_url: `${config.avatar}`, + embeds: [], + tts: false, + }; + + const embed = { + title: title, + timestamp: new Date(), + footer: { + text: config.stateName, + }, + }; + + if (options.text) { + embed.description = options.text; + } + + if (fields) embed.fields = fields; + + payload.embeds.push(embed); + + this.postWebhook(webhookUrl, payload) + .catch(err => logger.error(err)); // eslint-disable-line + } + + info(...args) { + logger.info(...args); + } + + error(...args) { + logger.error(...args); + } + + /** + * Post to a discord webhook + * @param {String} webhook The webhook to post to + * @param {Object} payload The json payload to send + * @return {Promise} + */ + postWebhook(webhook, payload) { + const [id, token] = webhook.split('/').slice(-2); + return this.manager.restClient.executeWebhook(id, token, payload); + } +} + +module.exports = Logger; diff --git a/src/core/clusterManager/Manager.js b/src/core/clusterManager/Manager.js new file mode 100644 index 0000000..788d71a --- /dev/null +++ b/src/core/clusterManager/Manager.js @@ -0,0 +1,184 @@ +const Eris = require('@dyno.gg/eris'); +const Logger = require('./Logger'); +const Commands = require('./Commands'); +const config = require('../config'); +const db = require('../database'); +const { Client, Server } = require('../rpc'); +const { Collection } = require('@dyno.gg/dyno-core'); +const { models } = db; + +/** + * @class Manager + */ +class Manager { + /** + * Create the cluster manager + * @param {String} strategy Sharding strategy + */ + constructor() { + this.processes = new Collection(); + this.clusters = new Collection(); + + process.on('uncaughtException', this.handleException.bind(this)); + process.on('unhandledRejection', this.handleRejection.bind(this)); + + this.globalConfig = null; + this.restClient = null; + + this.logger = new Logger(this); + this.methods = new Commands(this); + + this.pmClient = new Client(config.rpcHost || 'localhost', 5050); + + this.server = new Server(); + this.server.init(config.rpcHost || 'localhost', 5052, this.methods); + + db.connection.once('open', () => + this.connect().catch(err => { + throw err; + })); + } + + /** + * Unhandled rejection handler + * @param {Error|*} reason The reason the promise was rejected + * @param {Promise} p The promise that was rejected + */ + handleRejection(reason, p) { + try { + console.error('Unhandled rejection at: Promise ', p, 'reason: ', reason); // eslint-disable-line + } catch (err) { + console.error(reason); // eslint-disable-line + } + } + + handleException(err) { + if (!err || (typeof err === 'string' && !err.length)) { + return console.error('An undefined exception occurred.'); // eslint-disable-line + } + + console.error(err); // eslint-disable-line + } + + async connect() { + try { + const coll = await db.collection('clusters'); + const [globalConfig, _clusters] = await Promise.all([ + models.Dyno.findOne().lean(), + coll.find({ 'host.state': config.state }).toArray(), + ]); + + this.globalConfig = globalConfig; + + const token = config.isPremium ? config.client.token : this.globalConfig.prodToken || config.client.token; + this.restClient = new Eris(`Bot ${token}`, { restMode: true }); + + for (let c of _clusters) { + this.clusters.set(c.id, c); + } + + process.send('ready'); + let response = await this.pmClient.request('list', {}); + let processes = response && response.result ? response.result : []; + + if (!processes.length) { + this.logger.log(`[${process.pid}] Cluster manager online, starting ${this.clusters.size} clusters`); + return this.startup(); + } else { + this.logger.log(`[${process.pid}] Cluster manager online, resuming with ${this.clusters.size} clusters`); + } + + for (let proc of processes) { + if (this.processes.has(proc.id)) { continue; } + proc.client = new Client(config.rpcHost || 'localhost', proc.port); + this.processes.set(proc.id, proc); + } + } catch (err) { + return Promise.reject(err); + } + } + + async startup() { + for (let cluster of this.clusters.values()) { + await this.createProcess(cluster); + await this.wait(config.clusterStartDelay || 1500); + } + } + + async createProcess(cluster) { + try { + const response = await this.pmClient.request('create', { cluster }); + if (!response || !response.result) { + let error = response.error || response; + return Promise.reject(error); + } + let proc = response.result; + if (proc.port) { + proc.client = new Client(config.rpcHost || 'localhost', proc.port); + } + this.processes.set(proc.id, proc); + + const options = proc.options; + + if (options && options.hasOwnProperty('id')) { + this.logger.log(`[${proc.pid}] Cluster ${options.id} online`); + } + + return true; + } catch (err) { + return Promise.reject(err); + } + } + + async deleteProcess(cluster) { + try { + const proc = this.processes.find(p => p.options && p.options.id === cluster.id); + const response = await this.pmClient.request('delete', { id: proc.id }); + if (!response || !response.result) { + let error = response.error || response; + return Promise.reject(error); + } + this.clusters.delete(cluster.id); + if (proc) { + this.processes.delete(proc.id); + } + return true; + } catch (err) { + this.logger.error(err); + return Promise.reject(err); + } + } + + async restartProcess(proc) { + try { + const response = await this.pmClient.request('restart', { id: proc.id }); + if (!response || !response.result) { + let error = response.error || response; + return Promise.reject(error); + } + + proc = response.result; + if (proc.port && !proc.client) { + proc.client = new Client(config.rpcHost || 'localhost', proc.port); + } + this.processes.set(proc.id, proc); + + const cluster = proc.options; + + if (cluster && cluster.hasOwnProperty('id')) { + this.logger.log(`[${proc.pid}] Cluster ${cluster.id} online`); + } + + return true; + } catch (err) { + this.logger.error(err); + return Promise.reject(err); + } + } + + wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +module.exports = Manager; diff --git a/src/core/collections/CommandCollection.js b/src/core/collections/CommandCollection.js new file mode 100644 index 0000000..3e84d53 --- /dev/null +++ b/src/core/collections/CommandCollection.js @@ -0,0 +1,111 @@ +'use strict'; + +const each = require('async-each'); +const glob = require('glob-promise'); +const minimatch = require('minimatch'); +const { EventCollection, utils } = require('@dyno.gg/dyno-core'); +const { models } = require('../database'); +const logger = require('../logger'); + +/** + * @class CommandCollection + * @extends EventCollection + */ +class CommandCollection extends EventCollection { + /** + * A collection of commands + * @param {Object} config The Dyno configuration object + * @param {Dyno} dyno The Dyno instance + */ + constructor(config, dyno) { + super(); + + this.dyno = dyno; + this._client = dyno.client; + this._config = config; + + this.loadCommands(); + } + + /** + * Load commands + */ + async loadCommands() { + try { + var [files, moduleFiles] = await Promise.all([ + glob('**/*.js', { + cwd: this._config.paths.commands, + root: this._config.paths.commands, + absolute: true, + }), + glob('**/*.js', { + cwd: this._config.paths.modules, + root: this._config.paths.modules, + absolute: true, + }), + ]); + + moduleFiles = moduleFiles.filter(minimatch.filter('**/commands/*.js')); + files = files.concat(moduleFiles); + } catch (err) { + logger.error(err); + } + + utils.asyncForEach(files, file => { + if (!file.endsWith('.js')) return; + let load = () => { + var command = require(file); + this.register(command); + }; + load(); + // utils.time(load, file); + return; + }); + } + + /** + * Register command + * @param {Function} Command A Command class to register + */ + register(Command) { + if (Object.getPrototypeOf(Command).name !== 'Command') { + return logger.debug('[CommandCollection] Skipping unknown command'); + } + + // create the command + let command = new Command(this.dyno); + + // ensure command defines all required properties/methods + command.name = command.aliases[0]; + + logger.debug(`[CommandCollection] Registering command ${command.name}`); + + models.Command.update({ name: command.name, _state: this._config.state }, command.toJSON(), { upsert: true }) + .catch(err => logger.error(err)); + + if (command.aliases && command.aliases.length) { + for (let alias of command.aliases) { + this.set(alias, command); + } + } + } + + /** + * Unregister command + * @param {String} name Name of the command to unregister + */ + unregister(name) { + logger.info(`Unregistering command: ${name}`); + + const command = this.get(name); + if (!command) return; + + if (!command.aliases && !command.aliases.length) return; + for (let alias of command.aliases) { + logger.info(`Removing alias ${alias}`); + this.delete(alias); + } + } +} + +module.exports = CommandCollection; diff --git a/src/core/collections/GuildCollection.js b/src/core/collections/GuildCollection.js new file mode 100644 index 0000000..71deb67 --- /dev/null +++ b/src/core/collections/GuildCollection.js @@ -0,0 +1,424 @@ +'use strict'; + +const dot = require('dot-object'); +const each = require('async-each'); +const { Collection, utils } = require('@dyno.gg/dyno-core'); +const { models } = require('../../core/database'); +const redis = require('../../core/redis'); +const logger = require('../logger'); + +const premiumWebhook = 'https://canary.discordapp.com/api/webhooks/523575952744120321/xrh6uyOA0MOuMvHDAZLw5qws-jr9cDELU6xOoXZSTZcLlwN7lMHxt6yQD-dqRmJuLnnB'; + +/** + * @class GuildCollection + * @extends Collection + */ +class GuildCollection extends Collection { + /** + * A collection of guild configurations + * @param {Object} config The Dyno configuration object + * @param {Dyno} dyno The Dyno instance + */ + constructor(config, dyno) { + super(); + + this.dyno = dyno; + this.client = dyno.client; + this.config = config; + this._registering = new Set(); + this._activeThreshold = 3600 * 1000; // 24 hrs + + dyno.dispatcher.registerListener('guildCreate', this.guildCreated.bind(this)); + dyno.dispatcher.registerListener('guildDelete', this.guildDeleted.bind(this)); + + this.createWatch(); + + setInterval(this.uncacheData.bind(this), 150000); + } + + get globalConfig() { + return this.dyno.globalConfig; + } + + async createWatch() { + // We need an exclusive connection for publish / subscribe + this.subRedis = await redis.connect(); + + await this.subRedis.subscribe('guildConfig'); + + this.subRedis.on('message', (channel, message) => { + if (channel === 'guildConfig') { + this.guildUpdate(message); + } + }); + } + + guildUpdate(id) { + if (!this.client.guilds.has(id) || !this.has(id)) { + return; + } + + this.fetch(id).catch(err => logger.error(err)); + } + + /** + * Uncache guild configs + */ + uncacheData() { + each([...this.values()], guild => { + if ((Date.now() - guild.cachedAt) > 900) { + this.delete(guild._id); + } + }); + } + + /** + * Get or fetch a guild, no async/await for performance reasons + * @param {String} id Guild ID + * @returns {Promise} + */ + getOrFetch(id) { + const doc = this.get(id); + if (doc) { + doc.cachedAt = Date.now(); + return Promise.resolve(doc); + } + + return this.fetch(id).then(doc => { + if (!doc) { + return this.registerGuild(this.client.guilds.get(id)); + } + + doc.cachedAt = Date.now(); + this.set(doc._id, doc); + + return doc; + }); + } + + /** + * Fetch a guild from the database + * @param {String} id Guild ID + * @returns {Promise} + */ + fetch(id) { + let updateKeys = ['name', 'region', 'iconURL', 'ownerID', 'memberCount']; + return new Promise((resolve, reject) => { + models.Server.findAndPopulate(id) + .then(doc => { + if (!doc) { + return resolve(); + } + + doc = doc.toObject(); + let update = false; + + if (this.client.guilds.has(id)) { + const guild = this.client.guilds.get(id); + + if (!doc.longId) { + update = update || {}; + update.longId = guild.id; + } + + for (let key of updateKeys) { + if (guild[key] && doc[key] !== guild[key]) { + update = update || {}; + update[key] = guild[key]; + doc[key] = guild[key]; + } + } + + if (doc.deleted === true) { + update = update || {}; + update.deleted = false; + } + + if (!doc.clientID || doc.clientID !== this.config.client.id) { + if ((this.config.isPremium && doc.isPremium) || (!this.config.isPremium && !doc.isPremium)) { + update = update || {}; + update.clientID = this.config.client.id; + } + } + + if (!doc.lastActive || (Date.now() - doc.lastActive) > this._activeThreshold) { + update = update || {}; + update.lastActive = Date.now(); + this.setActive(guild, update.lastActive); + } + + if (update) { + this.update(id, { $set: update }).catch(err => logger.error(err)); + } + } + + this.set(doc._id, doc); + return resolve(doc); + }) + .catch(err => reject(err)); + }); + } + + /** + * Fired when a web update is received + * @param {String} id Guild ID + */ + // guildUpdate(id) { + // const guild = this.client.guilds.get(id); + // if (!guild) return; + + // logger.debug(`Web update for guild: ${id}`); + + // this.fetch(id).catch(err => logger.error(err)); + // } + + /** + * Wrapper to update guild config + * @param {String} id Guild ID + * @param {Object} update Mongoose update query + * @param {...*} args Any additional arguments to pass to the model + * @returns {Promise} + */ + update(id, update, ...args) { + if (update.$set) { + const serverlistColl = this.dyno.db.collection('serverlist_store'); + let serverlistUpdate = false; + if (update.$set.iconURL) { + serverlistUpdate = serverlistUpdate || {}; + serverlistUpdate.iconURL = update.$set.iconURL; + } + + if (update.$set.deleted === true) { + serverlistUpdate = serverlistUpdate || {}; + serverlistUpdate.markedForDeletionAt = Date.now(); + } + + if (update.$set.name) { + serverlistUpdate = serverlistUpdate || {}; + serverlistUpdate.name = update.$set.name; + } + + if (update.$set.memberCount) { + serverlistUpdate = serverlistUpdate || {}; + serverlistUpdate.memberCount = update.$set.memberCount; + } + + if (serverlistUpdate) { + serverlistColl.update({ id }, { $set: serverlistUpdate }); + } + + if (update.$set.deleted === false) { + serverlistColl.update({ id }, { $unset: { markedForDeletionAt: 1 } }); + } + } + + try { + const result = models.Server.update({ _id: id }, update, ...args); + this.dyno.redis.publish('guildConfig', id); + return result; + } catch (err) { + logger.error(err); + } + } + + // getGlobal() { + // if (this._globalConfig) return Promise.resolve(this._globalConfig); + // return Dyno.findOne().lean().exec(); + // } + + /** + * Guild created event listener + * @param {Guild} guild Guild object + */ + async guildCreated(guild) { + // if (this.config.handleRegion && !utils.regionEnabled(guild, this.config) && guild.id !== this.config.dynoGuild) { + // return this.client.uncacheGuild(guild.id); + // } + + logger.info(`Connected to server: ${guild.id} with ${guild.channels.size} channels and ${guild.members.size} members | ${guild.name}`); + + try { + var doc = await models.Server.findOne({ _id: guild.id }).lean().exec(); + if (!doc) { + return this.registerGuild(guild, true); + } + + if (this.config.isPremium && !doc.isPremium) { + this.postWebhook(premiumWebhook, { embeds: [{ title: 'Non-premium Guild Create', description: `Leaving Guild ${guild.id}`, color: 16729871 }] }); + return this.client.leaveGuild(guild.id); + } + + await this.update(guild.id, { $set: { deleted: false } }, { multi: true }); + this.set(doc._id, doc); + } catch (err) { + return logger.error(err); + } + + if (this.config.isPremium && !doc.premiumInstalled) { + doc.premiumInstalled = true; + this.set(doc._id, doc); + this.update(doc._id, { $set: { premiumInstalled: true } }).catch(err => logger.error(err)); + } + + return false; + } + + /** + * Guild deleted event listener + * @param {Guild} guild Guild object + */ + async guildDeleted(guild) { + if (guild.unavailable) return; + + if (this.config.isPremium) { + var guildConfig = await this.getOrFetch(guild.id); + if (!guildConfig || !guildConfig.isPremium) return; + if (guildConfig.isPremium && guildConfig.premiumInstalled) { + return this.update(guild.id, { $set: { premiumInstalled: false } }).catch(() => false); + } + + return; + } + + this.update(guild.id, { $set: { deleted: true, deletedAt: new Date() } }) + .catch(err => logger.error(err)); + } + + /** + * Register server in the database + * @param {Guild} guild Guild object + */ + registerGuild(guild, newGuild) { + if (!guild || !guild.id) { + return; + } + + if (this._registering.has(guild.id)) { + return; + } + + this._registering.add(guild.id); + + let doc = { + _id: guild.id, + longId: guild.id, + clientID: this.config.clientID, + name: guild.name, + iconURL: guild.iconURL, + ownerID: guild.ownerID, + memberCount: guild.memberCount, + region: guild.region || null, + modules: {}, + commands: {}, + lastActive: Date.now(), + deleted: false, + }; + + logger.info(`Registering guild: ${guild.id} ${guild.name}`); + + if (newGuild && !this.config.isPremium) { + this.dmOwner(guild); + } + + return new Promise((resolve, reject) => { + // add modules + for (let mod of this.dyno.modules.values()) { + // ignore core modules or modules that shouldn't be listed + if (mod.core && (mod.hasOwnProperty('list') && mod.list === false)) continue; + doc.modules[mod.module] = mod.enabled; + } + + for (let cmd of this.dyno.commands.values()) { + if (cmd.permissions === 'admin') continue; + + // ignore commands that belong to a module + if (this.dyno.modules.find(o => o.module === cmd.group) && doc.modules[cmd.group] === false) { + doc.commands[cmd.name] = false; + continue; + } + doc.commands[cmd.name] = (cmd.enabled || !cmd.disabled); + } + + this.update(doc._id, doc, { upsert: true }) + .then(() => { + doc.cachedAt = Date.now(); + + this.set(guild.id, doc); + return resolve(doc); + }) + .catch(err => { + logger.error(err); + return reject(err); + }) + .then(() => this._registering.delete(guild.id)); + }); + } + + setActive(guild, time) { + guild.lastActive = time; + this.dyno.redis.hset(`guild_activity:${this.config.client.id}:${this.config.clientOptions.maxShards}:${guild.shard.id}`, guild.id, time) + .catch(() => null); + } + + /** + * Attempt to send a DM to guild owner + * @param {Guild} guild Guild object + * @param {String} content Message to send + * @returns {Promise} + */ + async sendDM(guild, content) { + try { + var channel = await this.client.getDMChannel(guild.ownerID); + } catch (err) { + logger.error(err); + return Promise.reject(err); + } + + if (!channel) { + return Promise.reject('Channel is undefined or null.'); + } + + this.client.createMessage(channel, content).catch(() => false); + } + + /** + * DM Guild owner + * @param {Guild} guild Guild + */ + dmOwner(guild) { + if (this.config.test || this.config.beta) return; + if (this.config.handleRegion && !utils.regionEnabled(guild, this.config)) return; + + let msgArray = []; + + msgArray.push(`Thanks for adding me to your server. Just a few things to note.`); + msgArray.push('**1.** The default prefix is **`?`**.'); + msgArray.push('**2.** Setup the bot at **https://www.dynobot.net**'); + msgArray.push('**3.** Commands do not work in DM.'); + msgArray.push(`**4.** Join the Dyno discord server for questions, suggestions, or updates. **https://www.dynobot.net/discord**`); + + const content = msgArray.join('\n'); + + this.sendDM(guild, content) + .then(() => logger.debug('Successful DM to owner')) + .catch(() => { + if (guild.memberCount > 70) return; + this.client.createMessage(guild.defaultChannel, msgArray.join('\n')); + }); + } + + postWebhook(webhook, payload) { + return new Promise((resolve, reject) => + axios.post(webhook, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + ...payload, + }) + .then(resolve) + .catch(reject)); + } +} + +module.exports = GuildCollection; diff --git a/src/core/collections/ModuleCollection.js b/src/core/collections/ModuleCollection.js new file mode 100644 index 0000000..6d12d2c --- /dev/null +++ b/src/core/collections/ModuleCollection.js @@ -0,0 +1,203 @@ +'use strict'; + +const each = require('async-each'); +const glob = require('glob-promise'); +const minimatch = require('minimatch'); +const jsonSchema = require('mongoose_schema-json'); +const { Collection, utils } = require('@dyno.gg/dyno-core'); +const { models } = require('../database'); +const logger = require('../logger'); + +/** + * @class ModuleCollection + * @extends Collection + */ +class ModuleCollection extends Collection { + /** + * A collection of modules + * @param {Object} config The Dyno configuration object + * @param {Dyno} dyno The Dyno instance + */ + constructor(config, dyno) { + super(); + + this.dyno = dyno; + this._client = dyno.client; + this._config = config; + this._listenerCount = 0; + + this.moduleList = this._config.moduleList || []; + + this.loadModules(); + } + + unload() { + for (let module of this.values()) { + module._unload(); + this.delete(module.name); + } + } + + /** + * Load commands + */ + async loadModules() { + try { + var files = await glob('**/*.js', { + cwd: this._config.paths.modules, + root: this._config.paths.modules, + absolute: true, + }); + files = files.filter(f => !minimatch(f, '**/commands/*')); + } catch (err) { + logger.error(err); + } + + let modules = []; + + each(files, (file, next) => { + if (file.endsWith('.map')) return next(); + + const module = require(file); + + if (module.hasModules) { + modules = modules.concat(Object.values(module.modules)); + return next(); + } + + modules.push(require(file)); + return next(); + }, err => { + if (err) { + logger.error(err); + } + + utils.asyncForEach(modules, (module, next) => { + this.register(module); + return; + }, (e) => { + if (e) { + logger.error(e); + } + logger.info(`[ModuleCollection] Registered ${this.size} modules.`); + }); + }); + } + + /** + * Register module + * @param {Function} Module the module class + */ + register(Module) { + if (Object.getPrototypeOf(Module).name !== 'Module') { + return logger.debug(`[ModuleCollection] Skipping unknown module`); + } + + let module = new Module(this.dyno), + activeModule = this.get(module.name), + globalConfig = this.dyno.globalConfig; + + if (activeModule) { + logger.debug(`[ModuleCollection] Unloading module ${module.name}`); + activeModule._unload(); + this.delete(module.name); + } + + logger.debug(`[ModuleCollection] Registering module ${module.name}`); + + if (module.commands) { + const commands = Array.isArray(module.commands) ? module.commands : Object.values(module.commands); + each(commands, command => this.dyno.commands.register(command)); + } + + if (module.moduleModels) { + this.registerModels(module.moduleModels); + } + + // ensure the module defines all required properties/methods + module.ensureInterface(); + + if (!activeModule) { + const moduleCopy = module.toJSON(); + + if (module.settings) { + moduleCopy.settings = jsonSchema.schema2json(module.settings); + + models.Server.schema.add({ + [module.name.toLowerCase()]: module.settings, + }); + } + + moduleCopy._state = this._config.state; + + models.Module.findOneAndUpdate({ name: module.name, _state: this._config.state }, moduleCopy, { upsert: true, overwrite: true }) + .catch(err => logger.error(err)); + } + + this.set(module.name, module); + + if (this.moduleList.length && !this.moduleList.includes(Module.name)) { + return; + } + + if (globalConfig && globalConfig.modules.hasOwnProperty(module.name) && + globalConfig.modules[module.name] === false) return; + + each(this.dyno.dispatcher.events, (event, next) => { + if (!module[event]) return next(); + module.registerListener(event, module[event]); + this._listenerCount++; + next(); + }, err => { + if (err) logger.error(err); + this.get(module.name)._start(this._client); + }); + } + + registerModels(moduleModels) { + if(!moduleModels || !moduleModels.length || moduleModels.length === 0) { + return; + } + + each(moduleModels, (model, next) => { + if(typeof model !== 'object' || !model.name || (!model.skeleton && !model.schema)) { + next(); + return; + } + + logger.debug(`[ModuleCollection] Registering model: ${model.name}`); + + const schema = new this.dyno.db.Schema(model.skeleton || model.schema, model.options); + + this.dyno.db.registerModel({ name: model.name, schema }); + }); + } + + /** + * Enable or disable a module + * @param {String} id Guild id + * @param {String} name Module name + * @param {String|Boolean} enabled Enabled or disabled + * @returns {Promise} + */ + async toggle(id, name, enabled) { + let guildConfig = await this.dyno.guilds.getOrFetch(id), + guild = this._client.guilds.get(id), + module = this.get(name), + key = `modules.${name}`; + + enabled = enabled === 'true'; + + if (!guild || !guildConfig) + return Promise.reject(`Couldn't get guild or config for module ${name}.`); + + guildConfig.modules[name] = enabled; + + if (enabled && module && module.enable) module.enable(guild); + if (!enabled && module && module.disable) module.disable(guild); + + return this.dyno.guilds.update(guildConfig._id, { $set: { [key]: enabled } }); + } +} + +module.exports = ModuleCollection; diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 0000000..1fea5d6 --- /dev/null +++ b/src/core/config.js @@ -0,0 +1,108 @@ +'use strict'; + +const path = require('path'); +const pkg = require('../../package.json'); +const dot = require('dot-object'); + +const basePath = path.resolve(path.join(__dirname, '..')); + +const envkeyLoader = require('envkey/loader'); + +let config = envkeyLoader.fetch(); + +for (let k of Object.keys(config)) { + const index = config[k].indexOf('$typeof:'); + if (index > 0) { + const value = config[k].substr(0, index); + const type = config[k].substr(index).replace('$typeof:', ''); + + switch (type) { + case 'int': + case 'number': + config[k] = Number.parseInt(value); + break; + case 'bool': + case 'boolean': + config[k] = (value === 'true'); + break; + case 'json': + config[k] = JSON.parse(value); + break; + } + } +} + +config = dot.object(config); + +config.paths = { + base: basePath, + commands: path.join(basePath, 'commands'), + controllers: path.join(basePath, 'controllers'), + ipc: path.join(basePath, 'ipc'), + events: path.join(basePath, 'events'), + modules: path.join(basePath, 'modules'), +}; + +config.pkg = pkg; + +config.permissions = { + createInstantInvite: 1, + kickMembers: 2, + banMembers: 4, + administrator: 8, + manageChannels: 16, + manageGuild: 32, + addReactions: 64, + readMessages: 1024, + sendMessages: 2048, + sendTTSMessages: 4096, + manageMessages: 8192, + embedLinks: 16384, + attachFiles: 32768, + readMessageHistory: 65536, + mentionEveryone: 131072, + externalEmojis: 262144, + voiceConnect: 1048576, + voiceSpeak: 2097152, + voiceMuteMembers: 4194304, + voiceDeafenMembers: 8388608, + voiceMoveMembers: 16777216, + voiceUseVAD: 33554432, + changeNickname: 67108864, + manageNicknames: 134217728, + manageRoles: 268435456, + manageWebhooks: 536870912, + manageEmojis: 1073741824, +}; + +config.permissionsMap = { + createInstantInvite: 'Create Instant Invite', + kickMembers: 'Kick Members', + banMembers: 'Ban Members', + administrator: 'Administrator', + manageChannels: 'Manage Channels', + manageGuild: 'Manage Server', + addReactions: 'Add Reactions', + readMessages: 'Read Messages', + sendMessages: 'Send Messages', + sendTTSMessages: 'Send TTS Messages', + manageMessages: 'Manage Messages', + embedLinks: 'Embed Links', + attachFiles: 'Attach Files', + readMessageHistory: 'Read Message History', + mentionEveryone: 'Mention Everyone', + externalEmojis: 'External Emojis', + voiceConnect: 'Connect', + voiceSpeak: 'Speak', + voiceMuteMembers: 'Mute Members', + voiceDeafenMembers: 'Deafen Members', + voiceMoveMembers: 'Move Members', + voiceUseVAD: 'Use Voice Activity', + changeNickname: 'Change Nickname', + manageNicknames: 'Manage Nicknames', + manageRoles: 'Manage Roles', + manageWebhooks: 'Manage Webhooks', + manageEmojis: 'Manage Emojis', +}; + +module.exports = config; diff --git a/src/core/database.js b/src/core/database.js new file mode 100644 index 0000000..4b9d512 --- /dev/null +++ b/src/core/database.js @@ -0,0 +1,24 @@ +'use strict'; + +const DataFactory = require('@dyno.gg/datafactory'); +const config = require('./config'); + +const dbString = config.mongo.dsn; + +if (!dbString) { + throw new Error('Missing environment variable CLIENT_MONGO_URL.'); +} + +const db = new DataFactory({ + dbString, + disableReplica: config.mongo.disableReplica || false, + logger: { + level: config.logLevel || 'error', + sentry: { + level: config.sentry.logLevel, + dsn: config.sentry.dsn, + }, + }, +}); + +module.exports = db; diff --git a/src/core/logger.js b/src/core/logger.js new file mode 100644 index 0000000..2b53537 --- /dev/null +++ b/src/core/logger.js @@ -0,0 +1,98 @@ +'use strict'; + +const util = require('util'); +const moment = require('moment'); +const winston = require('winston'); +const config = require('./config'); +const Sentry = require('./transports/winston-sentry'); + +/** + * @class Logger + */ +class Logger { + /** + * @prop {Array} transports + * @prop {Boolean} exitOnError + */ + constructor() { + this.transports = [ + new (winston.transports.Console)({ + colorize: true, + level: config.logLevel || 'info', + debugStdout: true, + // handleExceptions: true, + // humanReadableUnhandledException: true, + timestamp: () => new Date(), + formatter: this._formatter.bind(this), + }), + ]; + + if (config.sentry.dsn) { + this.transports.push(new Sentry({ + // patchGlobal: true, + level: config.sentry.logLevel || 'error', + dsn: config.sentry.dsn, + logger: config.stateName, + })); + } + + this.exitOnError = false; + + return new (winston.Logger)(this); + } + + /** + * Custom formatter for console + * @param {Object} options Formatter options + * @returns {String} + * @private + */ + _formatter(options) { + let ts = util.format('[%s]', moment(options.timestamp()).format('HH:mm:ss')), + level = winston.config.colorize(options.level); + + if (process.env.hasOwnProperty('clusterId')) { + ts = `[C${process.env.clusterId}] ${ts}`; + } + + if (!options.message.length && options.meta instanceof Error) { + options.message = options.meta + options.meta.stack; + } + + if (options.meta && options.meta.guild && typeof options.meta.guild !== 'string') { + if (options.meta.guild.shard) { + options.meta.shard = options.meta.guild.shard.id; + } + options.meta.guild = options.meta.guild.id; + } + + switch (options.level) { + case 'debug': + ts += ' ⚙ '; + break; + case 'info': + ts += ' 🆗 '; + break; + case 'error': + ts += ' 🔥 '; + break; + case 'warn': + ts += ' ☣ '; + break; + case 'silly': + ts += ' 💩 '; + break; + } + + let message = ts + ' ' + level + ': ' + (undefined !== options.message ? options.message : '') + + (options.meta && Object.keys(options.meta).length ? '\n\t' + util.inspect(options.meta) : ''); + + if (options.colorize === 'all') { + return winston.config.colorize(options.level, message); + } + + return message; + } +} + +module.exports = new Logger(); diff --git a/src/core/managers/EventManager.js b/src/core/managers/EventManager.js new file mode 100644 index 0000000..7a53536 --- /dev/null +++ b/src/core/managers/EventManager.js @@ -0,0 +1,278 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); +const each = require('async-each'); +const logger = require('../logger'); + +class EventManager { + constructor(dyno) { + this.dyno = dyno; + this._client = dyno.client; + this._config = dyno.config; + + this._handlers = new Map(); + this._listeners = {}; + this._boundListeners = new Map(); + this.chunkedGuilds = new Map(); + this.disabledGuilds = new Map(); + + this.events = [ + 'channelCreate', + 'channelDelete', + 'guildBanAdd', + 'guildBanRemove', + 'guildCreate', + 'guildDelete', + 'guildMemberAdd', + 'guildMemberRemove', + 'guildMemberUpdate', + 'guildRoleCreate', + 'guildRoleDelete', + 'guildRoleUpdate', + 'messageCreate', + 'messageDelete', + 'messageDeleteBulk', + 'messageUpdate', + 'userUpdate', + 'voiceChannelJoin', + 'voiceChannelLeave', + 'voiceChannelSwitch', + 'messageReactionAdd', + 'messageReactionRemove', + 'messageReactionRemoveAll', + ]; + + this.registerHandlers(); + } + + get client() { + return this._client; + } + + get config() { + return this._config; + } + + /** + * Register the root event handlers from the events directory + */ + registerHandlers() { + utils.readdirRecursive(this._config.paths.events).then(files => { + each(files, (file, next) => { + if (file.endsWith('.map')) return next(); + const handler = require(file); + if (!handler || !handler.name) return logger.error('Invalid handler.'); + this._handlers.set(handler.name, handler); + logger.debug(`[EventManager] Registering ${handler.name} handler`); + return next(); + }, err => { + if (err) logger.error(err); + logger.info(`[EventManager] Registered ${this.events.size} events.`); + }); + }).catch(err => logger.error(err)); + } + + /** + * Bind event listeners and store a reference + * to the bound listener so they can be unregistered. + */ + bindListeners() { + let listenerCount = 0; + for (let event in this._listeners) { + // Bind the listener so it can be removed + this._boundListeners[event] = this.createListener.bind(this, event); + // Register the listener + this.client.on(event, this._boundListeners[event]); + listenerCount++; + } + logger.info(`[EventManager] Bound ${listenerCount} listeners.`); + } + + /** + * Register event listener + * @param {String} event Event name + * @param {Function} listener Event listener + * @param {String} [module] module name + */ + registerListener(event, listener, module) { + // Register but don't bind listeners before the client is ready + if (!this._listeners[event] || !this._listeners[event].find(l => l.listener === listener)) { + this._listeners[event] = this._listeners[event] || []; + this._listeners[event].push({ module: module || null, listener: listener }); + return; + } + + // Remove the listener from listeners if it exists, and re-add it + let index = this._listeners[event].findIndex(l => l.listener === listener); + if (index > -1) this._listeners[event].splice(index, 1); + + this._listeners[event].push({ module: module, listener: listener }); + + this.client.removeListener(event, this._boundListeners[event]); + + // Bind the listener so it can be removed + this._boundListeners[event] = this.createListener.bind(this, event); + + // Register the bound listener + this.client.on(event, this._boundListeners[event]); + } + + /** + * Deregister event listener + * @param {String} event Event name + * @param {Function} listener Event listener + */ + unregisterListener(event, listener) { + let index = this._listeners[event].findIndex(l => l.listener === listener); + if (index > -1) this._listeners[event].splice(index, 1); + } + + awaitChunkState(guild, cb) { + if (guild.members && guild.members.size >= (guild.memberCount * 0.9)) { + this.chunkedGuilds.set(guild.id, 2); + return cb(); + } else { + setTimeout(() => this.awaitChunkState(guild, cb), 100); + } + } + + /** + * Create an event listener + * @param {String} event Event name + * @param {...*} args Event arguments + */ + createListener(event, ...args) { + if (!this._listeners[event]) return; + + const handler = this._handlers.get(event); + + // Check if a root handler exists before calling module listeners + if (handler) { + return handler(this, ...args).then(async (e) => { + if (!e || !e.guildConfig) { + return; + // return logger.warn(`${event} no event or guild config`); + } + + if (e.guild.id && this.disabledGuilds.has(e.guild.id)) { + return; + } + + // Check if guild is enabled for the app state + if (e.guild.id !== this._config.dynoGuild && event !== 'messageCreate') { + if (!this.guildEnabled(e.guildConfig, e.guild.id)) return; + } + + let chunkState = this.chunkedGuilds.get(e.guild.id); + + if (this.config.lazyChunking) { + if (!chunkState) { + chunkState = 1; + this.chunkedGuilds.set(e.guild.id, 1); + e.guild.fetchAllMembers(); + } + if (chunkState === 2) { + if (e.guild.members.size < (e.guild.memberCount * 0.9)) { + chunkState = 1; + this.chunkedGuilds.set(e.guild.id, 1); + e.guild.fetchAllMembers(); + } + } + if (chunkState !== 2) { + await new Promise(resolve => this.awaitChunkState(e.guild, resolve)); + } + } + + each(this._listeners[event], o => { + if (o.module && e.guild && e.guildConfig) { + if (!this.moduleEnabled(e.guild, e.guildConfig, o.module)) return; + } + + o.listener(e); + }); + }).catch(err => err ? logger.error(err) : false); + } + + // No root handler exists, execute the module listeners + each(this._listeners[event], o => o.listener(...args)); + } + + /** + * Check if an event handler should continue or not based on app state and guild config + * @param {Object} guildConfig Guild configuration + * @param {String} guildId Guild ID + * @returns {Boolean} + */ + guildEnabled(guildConfig, guildId) { + const guild = this._client.guilds.get(guildId); + + // handle events based on region, ignore in dev + if (!guild) return false; + if (this._config.handleRegion && !utils.regionEnabled(guild, this._config)) return false; + + if (this.disabledGuilds.has(guildId)) { + return false; + } + + if (this._config.test) { + if (this._config.testGuilds.includes(guildId) || guildConfig.test) return true; + return false; + } + + // premium checks + if (!this._config.isPremium && guildConfig.isPremium && guildConfig.premiumInstalled) { + return false; + } + if (this._config.isPremium && (!guildConfig.isPremium || !guildConfig.premiumInstalled)) { + return false; + } + + if (!this._config.isPremium && guildConfig.clientID && guildConfig.clientID !== this._config.client.id) { + return false; + } + + // Shared state, receive all events + if (this._config.shared) return true; + + if (guildConfig.beta) { + // Guild is using beta, but app state is not test/beta + if (!this._config.test && !this._config.beta) { + return false; + } + } else if (this._config.beta) { + // App state is beta, but guild is not. + return false; + } + + return true; + } + + /** + * Check if a module is enabled or should execute code + * @param {Guild} guild Guild object + * @param {Object} guildConfig Guild configuration + * @param {String} module Module name + * @return {Boolean} + */ + moduleEnabled(guild, guildConfig, module) { + // Ignore events before the client is ready or guild is cached + if (!this.dyno.isReady || !guild || !guildConfig) return false; + if (!guildConfig.modules) return false; + + const name = module.module || module.name; + + // check if globally disabled + const globalConfig = this.dyno.globalConfig; + if (globalConfig && globalConfig.modules.hasOwnProperty(name) && + globalConfig.modules[name] === false) return false; + + // check if module is disabled + if (guildConfig.modules.hasOwnProperty(name) && guildConfig.modules[name] === false) { + return false; + } + + return true; + } +} + +module.exports = EventManager; diff --git a/src/core/managers/IPCManager.js b/src/core/managers/IPCManager.js new file mode 100644 index 0000000..2a0d120 --- /dev/null +++ b/src/core/managers/IPCManager.js @@ -0,0 +1,146 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); +const EventEmitter = require('eventemitter3'); +const each = require('async-each'); +const logger = require('../logger'); + +/** + * @class IPCManager + * @extends EventEmitter + */ +class IPCManager extends EventEmitter { + /** + * Manages the IPC communications with the shard manager + * @param {Dyno} dyno The Dyno instance + * + * @prop {Number} id Shard ID + * @prop {Number} pid Process ID + * @prop {Map} commands Collection of IPC commands + */ + constructor(dyno) { + super(); + + const config = this._config = dyno.config; + + this.dyno = dyno; + this.client = dyno.client; + + this.id = dyno.clientOptions.clusterId || dyno.clientOptions.shardId || 0; + this.pid = process.pid; + this.commands = new Map(); + + process.on('message', this.onMessage.bind(this)); + + utils.readdirRecursive(this._config.paths.ipc).then(files => { + each(files, (file, next) => { + if (file.endsWith('.map')) return next(); + this.register(require(file)); + return next(); + }, err => { + if (err) logger.error(err); + logger.info(`[IPCManager] Registered ${this.commands.size} IPC commands.`); + }); + }).catch(err => logger.error(err)); + } + + /** + * Send a command or event to the shard manager + * @param {String} event Event to send + * @param {Mixed} data The data to send + */ + send(event, data) { + if (!process.send) return; + try { + process.send({ + op: event, + d: data || null, + }); + } catch (err) { + logger.error(`IPC Error Caught:`, err); + } + } + + /** + * Fired when the shard receives a message + * @param {Object} message The message object + * @returns {*} + */ + onMessage(message) { + // op for internal dyno messages, type for prom-client cluster messages + if (!message.op && !message.type) { + return logger.warn('Received IPC message with no op or type.'); + } + + if (['resp', 'broadcast'].includes(message.op)) return; + + if (this[message.op]) { + try { + return this[message.op](message); + } catch (err) { + return this.logger.error(err); + } + } + + const command = this.commands.get(message.op); + + if (!command) return; + + try { + return command(this.dyno, this._config, message); + } catch (err) { + this.logger.error(err); + } + + this.emit(message.op, message.d); + } + + /** + * Send a command and await a response from the shard manager + * @param {String} op Op to send + * @param {Object} d The data to send + * @returns {Promise} + */ + awaitResponse(op, d) { + if (!process.send) return; + + return new Promise((resolve, reject) => { + const awaitListener = (msg) => { + if (!['resp', 'error'].includes(msg.op)) return; + + process.removeListener('message', awaitListener); + + if (msg.op === 'resp') return resolve(msg.d); + if (msg.op === 'error') return reject(msg.d); + }; + + const payload = { op: op }; + if (d) payload.d = d; + + process.on('message', awaitListener); + try { + process.send(payload); + } catch (err) { + logger.error(`IPC Error Caught:`, err); + } + + setTimeout(() => { + process.removeListener('message', awaitListener); + reject('IPC Timed out.'); + }, 5000); + }); + } + + /** + * Register an IPC command + * @param {Function} command The command to execute + * @returns {*|void} + */ + register(command) { + if (!command || !command.name) return logger.error('[IPCManager] Invalid command.'); + logger.debug(`[IPCManager] Registering ipc command ${command.name}`); + this.commands.set(command.name, command); + } +} + +module.exports = IPCManager; diff --git a/src/core/managers/LangManager.js b/src/core/managers/LangManager.js new file mode 100644 index 0000000..4287315 --- /dev/null +++ b/src/core/managers/LangManager.js @@ -0,0 +1,348 @@ +'use strict'; + +const dot = require('dot-prop'); +const each = require('async-each'); +const glob = require('glob-promise'); +const path = require('path'); +const { Collection } = require('@dyno.gg/dyno-core'); + +class LangManager extends Collection { + constructor(localePath) { + super(); + + this.localePath = localePath; + } + + async loadLocales() { + try { + var files = await glob('**/*.json', { + cwd: path.resolve(this.localePath), + root: path.resolve(this.localePath), + absolute: true, + }); + } catch (err) { + console.error(err); + return Promise.reject(err); + } + + each(files, file => { + const locale = path.dirname(file).split(path.sep).pop(); + this.set(locale, new I18n(locale, file)); + }); + } + + t(locale, ...args) { + locale = locale || 'en'; + const fallbackLang = this.get('en'); + if (!this.has(locale)) { + locale = 'en'; + } + return this.get(locale).__(fallbackLang, ...args); + } +} + +class I18n { + constructor(lang, filePath) { + this._lang = lang; + this._filePath = filePath; + this._locale = require(filePath); + } + reload() { + delete require.cache[this._filePath]; + this._locale = require(this._filePath); + } + // Get the rule for pluralization + // http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html + get_rule(count, language) { + switch (language){ + // nplurals=2; plural=(n > 1); + case 'ach': + case 'ak': + case 'am': + case 'arn': + case 'br': + case 'fil': + case 'fr': + case 'gun': + case 'ln': + case 'mfe': + case 'mg': + case 'mi': + case 'oc': + case 'pt_BR': + case 'tg': + case 'ti': + case 'tr': + case 'uz': + case 'wa': + return (count > 1) ? 1 : 0; + break; + + // nplurals=2; plural=(n != 1); + case 'af': + case 'an': + case 'anp': + case 'as': + case 'ast': + case 'az': + case 'bg': + case 'bn': + case 'brx': + case 'ca': + case 'da': + case 'doi': + case 'de': + case 'el': + case 'en': + case 'eo': + case 'es': + case 'es_AR': + case 'et': + case 'eu': + case 'ff': + case 'fi': + case 'fo': + case 'fur': + case 'fy': + case 'gl': + case 'gu': + case 'ha': + case 'he': + case 'hi': + case 'hne': + case 'hu': + case 'hy': + case 'ia': + case 'it': + case 'kl': + case 'kn': + case 'ku': + case 'lb': + case 'mai': + case 'ml': + case 'mn': + case 'mni': + case 'mr': + case 'nah': + case 'nap': + case 'nb': + case 'ne': + case 'nl': + case 'nn': + case 'no': + case 'nso': + case 'or': + case 'pa': + case 'pap': + case 'pms': + case 'ps': + case 'pt': + case 'rm': + case 'rw': + case 'sat': + case 'sco': + case 'sd': + case 'se': + case 'si': + case 'so': + case 'son': + case 'sq': + case 'sv': + case 'sw': + case 'ta': + case 'te': + case 'tk': + case 'ur': + case 'yo': + return (count != 1) ? 1 : 0; + + // nplurals=1; plural=0; + case 'ay': + case 'bo': + case 'cgg': + case 'dz': + case 'fa': + case 'id': + case 'ja': + case 'jbo': + case 'ka': + case 'kk': + case 'km': + case 'ko': + case 'ky': + case 'lo': + case 'ms': + case 'my': + case 'sah': + case 'su': + case 'th': + case 'tt': + case 'ug': + case 'vi': + case 'wo': + case 'zh': + case 'jv': + return 0; + + // nplurals=2; plural=(n%10!=1 || n%100==11); + case 'is': + return (count % 10!=1 || count % 100==11) ? 1 : 0; + + // nplurals=4; plural=(n==1) ? 0 : (n==2) ? 1 : (n == 3) ? 2 : 3; + case 'kw': + return (count==1) ? 0 : (count==2) ? 1 : (count == 3) ? 2 : 3; + + // nplurals=3; plural=(n % 10==1 && n % 100!=11 ? 0 : n % 10>=2 && n % 10 <= 4 && (n % 100 < 10 || n % 100>=20) ? 1 : 2); + case 'uk': + case 'sr': + case 'ru': + case 'hr': + case 'bs': + case 'be': + return count % 10 === 1 && count % 100 !== 11 ? 0 : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20) ? 1 : 2; + + // nplurals=3; plural=(n === 0 ? 0 : n === 1 ? 1 : 2); + case 'mnk': + return count === 0 ? 0 : count === 1 ? 1 : 2; + + // nplurals=3; plural=(n === 1) ? 0 : (n >= 2 && n <= 4) ? 1 : 2; + case 'sk': + return (count === 1) ? 0 : (count >= 2 && count <= 4) ? 1 : 2; + + // nplurals=3; plural=(n === 1 ? 0 : (n === 0 || (n % 100 > 0 && n % 100 < 20)) ? 1 : 2); + case 'ro': + return count === 1 ? 0 : (count === 0 || (count % 100 > 0 && count % 100 < 20)) ? 1 : 2; + + // nplurals=6; plural=(n === 0 ? 0 : n === 1 ? 1 : n === 2 ? 2 : n % 100 >= 3 && n % 100 <= 10 ? 3 : n % 100 >= 11 ? 4 : 5); + case 'ar': + return count === 0 ? 0 : count === 1 ? 1 : count === 2 ? 2 : count % 100 >= 3 && count % 100 <= 10 ? 3 : count % 100 >= 11 ? 4 : 5; + + // nplurals=3; plural=(n === 1) ? 0 : (n >= 2 && n <= 4) ? 1 : 2; + case 'cs': + return count === 1 ? 0 : (count >= 2 && count <= 4) ? 1 : 2; + + // countplurals=3; plural=(n === 1) ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2; + case 'csb': + return (count === 1) ? 0 : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20) ? 1 : 2; + + // nplurals=4; plural=(n === 1) ? 0 : (n === 2) ? 1 : (n !== 8 && n !== 11) ? 2 : 3; + case 'cy': + return (count === 1) ? 0 : (count === 2) ? 1 : (count !== 8 && count !== 11) ? 2 : 3; + + // nplurals=5; plural=n === 1 ? 0 : n === 2 ? 1 : (n>2 && n<7) ? 2 :(n>6 && n<11) ? 3 : 4; + case 'ga': + return count === 1 ? 0 : count === 2 ? 1 : (count>2 && count<7) ? 2 :(count>6 && count<11) ? 3 : 4; + + // nplurals=4; plural=(n === 1 || n === 11) ? 0 : (n === 2 || n === 12) ? 1 : (n > 2 && n < 20) ? 2 : 3; + case 'gd': + return (count === 1 || count === 11) ? 0 : (count === 2 || count === 12) ? 1 : (count > 2 && count < 20) ? 2 : 3; + + // nplurals=3; plural=(n % 10 === 1 && n % 100 !== 11 ? 0 : n % 10 >= 2 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2); + case 'it': + return count % 10 === 1 && count % 100 !== 11 ? 0 : count % 10 >= 2 && (count % 100 < 10 || count % 100 >= 20) ? 1 : 2; + + // nplurals=3; plural=(n % 10 === 1 && n % 100 !== 11 ? 0 : n !== 0 ? 1 : 2); + case 'lv': + return count % 10 === 1 && count % 100 !== 11 ? 0 : count !== 0 ? 1 : 2; + + // nplurals=2; plural= n === 1 || n % 10 === 1 ? 0 : 1; + case 'mk': { + return count === 1 || count % 10 === 1 ? 0 : 1; + } + + // nplurals=4; plural=(n === 1 ? 0 : n === 0 || ( n % 100 > 1 && n % 100 < 11) ? 1 : (n % 100 > 10 && n % 100 < 20 ) ? 2 : 3); + case 'mt': + return count === 1 ? 0 : count === 0 || (count % 100 > 1 && n % 100 < 11) ? 1 : (count % 100 > 10 && count % 100 < 20 ) ? 2 : 3; + + // nplurals=3; plural=(n === 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2); + case 'pl': + return count === 1 ? 0 : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20) ? 1 : 2; + + // nplurals=4; plural=(n % 100 === 1 ? 1 : n % 100 === 2 ? 2 : n % 100 === 3 || n % 100 === 4 ? 3 : 0); + case 'sl': + return count % 100 === 1 ? 1 : count % 100 === 2 ? 2 : count % 100 === 3 || count % 100 === 4 ? 3 : 0; + + default: + return 0; + } + } + + __(fallbackLang, string, values) { + //return translation of the original sting if did not find the translation + // let translation = string; + let translation = dot.get(this._locale, string, null); + + if (fallbackLang && !translation) { + translation = fallbackLang.__(null, string, values); + } + + // get the corresponding translation from the file + // if (typeof this._locale[string] != 'undefined' && typeof this._locale[string][this._lang] != 'undefined') { + // translation = this._locale[string][this._lang]; + // } else if (fallbackLang) { + // translation = fallbackLang.__(null, string, values); + // } + + // If the string have place to render values withen + if ((/{{.+?}}/g).test(translation)) { + // get all the parts needed to be replaced + var matches = translation.match(/{{.+?}}/g); + // loop on each match + for (const index in matches) { + // get the match {{example}} + const match = matches[index]; + // get the word in the match example + let match_word = (match.replace('}}', '')).replace('{{', ''); + let match_search; + + // translate the word if was passed in the values var + if (values && values[match_word] != undefined) { + translation = translation.replace(match, values[match_word]); + continue; // move to the next word in the loop + } else { + // match_search = dot.get(this._locale, match_word); + // if (match_search != undefined) { + if (typeof this._locale[match_word] != 'undefined') { + // If the translation is there in the file then translate it directly + translation = translation.replace(match, match_search); + continue;//move to the next word in the loop + } + } + + // if the matched word have a count + if ((/\|\|.+/g).test(match_word)) { + const temp_array = match_word.split('||'); + // update the matched word + match_word = temp_array[0]; + // get the variable of the count for the word + const item_count_variable = temp_array[1]; + + // get the value form values passed to this function + // TODO through error if not found in values + const item_count = values[item_count_variable]; + + // will get the rule or for pluralization based on the lang + const rule = this.get_rule(item_count, this._lang); + // match_search = dot.get(this._locale, match_word); + + if (typeof this._locale[match_word] == 'object') { + // if (typeof match_search == 'object') { + translation = translation.replace(match, match_search[rule]); + } else { + translation = translation.replace(match, match_search); + } + } else { + if (typeof values == 'object') { + translation = translation.replace(match, values[match_word]); + } else { + translation = translation.replace(match, match_search); + } + } + } + } + + return translation; + } +} + +module.exports = LangManager; diff --git a/src/core/managers/PagerManager.js b/src/core/managers/PagerManager.js new file mode 100644 index 0000000..bc27299 --- /dev/null +++ b/src/core/managers/PagerManager.js @@ -0,0 +1,37 @@ +'use strict'; + +const { Collection, Pager } = require('@dyno.gg/dyno-core'); + +/** + * @class PagerManager + * @extends Collection + */ +class PagerManager extends Collection { + /** + * PagerManager constructor + * @param {Dyno} dyno Dyno core instance + */ + constructor(dyno) { + super(); + this.dyno = dyno; + } + + /** + * Create a pager + * @param {Object} options Pager options + * @param {String|GuildChannel} options.channel The channel this pager will be created in + * @param {User|Member} options.user The user this pager is created for + * @param {Object} options.embed The embed object to be sent without fields + * @param {Object[]} options.fields All embed fields that will be paged + * @param {Number} [options.pageLimit=10] The number of items per page, max 25, default 10 + */ + create(options) { + if (!options || !options.channel || !options.user) return; + let id = `${options.channel.id}.${options.user.id}`; + let pager = new Pager(this, id, options); + this.set(id, pager); + return pager; + } +} + +module.exports = PagerManager; diff --git a/src/core/managers/PermissionsManager.js b/src/core/managers/PermissionsManager.js new file mode 100644 index 0000000..0ac62b9 --- /dev/null +++ b/src/core/managers/PermissionsManager.js @@ -0,0 +1,105 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +class PermissionsManager { + constructor(dyno) { + this._config = dyno.config; + this.dyno = dyno; + } + + /** + * Check if user is bot admin + * @param {User|Member} user User object + * @returns {Boolean} + */ + isAdmin(user) { + if (!user || !user.id) return false; + if (this.dyno.globalConfig.developers && this.dyno.globalConfig.developers.includes(user.id)) { + return true; + } + return (user.id === this._config.client.admin); + } + + isOverseer(user) { + if (!user || !user.id) return false; + return (this.dyno.globalConfig.overseers && this.dyno.globalConfig.overseers.includes(user.id)); + } + + /** + * Check if user is server admin + * @param {Member} member Member object + * @param {GuildChannel} channel Channel object + * @returns {Boolean} + */ + isServerAdmin(member, channel) { + // ignore DM + if (!member || channel.type !== 0) return false; + // let permissions = member.permissionsFor(channel); + return (member.id === channel.guild.ownerID || (member.permission && + (member.permission.has('administrator') || member.permission.has('manageGuild')))); + } + + /** + * Check if user is server mod + * @param {Member} member Guild member object + * @param {GuildChannel} channel Channel object + * @returns {Boolean} + */ + isServerMod(member, channel) { + // ignore DM + if (!member || channel.type !== 0) return false; + + const guildConfig = this.dyno.guilds.get(channel.guild.id); + + if (this.isAdmin(member) || this.isServerAdmin(member, channel)) return true; + + // server config may not have loaded yet + if (!guildConfig) return false; + + // check mod roles + if (guildConfig.modRoles && member.roles && member.roles.find(r => guildConfig.modRoles.includes(r))) { + return true; + } + + // sanity check + if (!guildConfig.mods) return false; + + return guildConfig.mods.includes(member.id); + } + + canOverride(channel, member, command) { + if (!member || !channel) return null; + + const guildConfig = this.dyno.guilds.get(channel.guild.id); + + if (!guildConfig.permissions || !guildConfig.permissions.length) return null; + + const channelPerms = guildConfig.channelPermissions; + const rolePerms = guildConfig.rolePermissions; + + let canOverride = null; + + if (channelPerms && channelPerms[channel.id] && channelPerms[channel.id].commands.hasOwnProperty(command)) { + canOverride = channelPerms[channel.id].commands[command]; + } + + if (!rolePerms) return canOverride; + + const roles = utils.sortRoles(channel.guild.roles); + + for (let role of roles) { + if (!rolePerms[role.id]) continue; + if (member.roles.indexOf(role.id) === -1) continue; + + if (rolePerms[role.id].commands.hasOwnProperty(command)) { + canOverride = rolePerms[role.id].commands[command]; + break; + } + } + + return canOverride; + } +} + +module.exports = PermissionsManager; diff --git a/src/core/managers/RPCManager.js b/src/core/managers/RPCManager.js new file mode 100644 index 0000000..b1f8069 --- /dev/null +++ b/src/core/managers/RPCManager.js @@ -0,0 +1,147 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); +const Hemera = require('nats-hemera'); +const HemeraJoi = require('hemera-joi'); +const Nats = require('nats'); +const EventEmitter = require('eventemitter3'); +const each = require('async-each'); +const logger = require('../logger'); + +/** + * @class RPCManager + * @extends EventEmitter + */ +class RPCManager extends EventEmitter { + /** + * Manages RPC communications + * @param {Dyno} dyno The Dyno instance + */ + constructor(dyno) { + super(); + + this.dyno = dyno; + this.config = dyno.config; + this.client = dyno.client; + this.clusterConfig = dyno.clientOptions; + + this.id = dyno.options.clusterId || dyno.options.shardId || 0; + this.pid = process.pid; + + this.commands = new Map(); + + this.nats = Nats.connect({ url: 'nats://ares.dyno.gg:4222' }); + this.hemera = new Hemera(this.nats, { logLevel: 'info' }); + this.hemera.use(HemeraJoi); + this.hemera.ready(this.onReady.bind(this)); + } + + onReady() { + logger.info(`[RPCManager] ready.`); + utils.readdirRecursive(this.config.paths.ipc).then(files => { + each(files, (file, next) => { + if (file.endsWith('.map')) return next(); + this.register(require(file)); + return next(); + }, err => { + if (err) logger.error(err); + logger.info(`[RPCManager] Registered ${this.commands.size} RPC commands.`); + }); + }).catch(err => logger.error(err)); + } + + /** + * Send a command or event to the shard manager + * @param {String} event Event to send + * @param {Mixed} data The data to send + */ + send(event, data) { + if (!process.send) return; + process.send({ + op: event, + d: data || null, + }); + } + + /** + * Fired when the shard receives a message + * @param {Object} message The message object + * @returns {*} + */ + onMessage(message) { + if (!message.op) { + return logger.warn('Received RPC message with no op.'); + } + + if (['resp', 'broadcast'].includes(message.op)) return; + + if (this[message.op]) { + try { + return this[message.op](message); + } catch (err) { + return this.logger.error(err); + } + } + + const command = this.commands.get(message.op); + + if (!command) return; + + try { + return command(this.dyno, this.config, message); + } catch (err) { + this.logger.error(err); + } + + this.emit(message.op, message.d); + } + + /** + * Send a command and await a response from the shard manager + * @param {String} op Op to send + * @param {Object} d The data to send + * @returns {Promise} + */ + awaitResponse(op, d) { + if (!process.send) return; + + return new Promise((resolve, reject) => { + const awaitListener = (msg) => { + if (!['resp', 'error'].includes(msg.op)) return; + + process.removeListener('message', awaitListener); + + if (msg.op === 'resp') return resolve(msg.d); + if (msg.op === 'error') return reject(msg.d); + }; + + const payload = { op: op }; + if (d) payload.d = d; + + process.on('message', awaitListener); + process.send(payload); + + setTimeout(() => { + process.removeListener('message', awaitListener); + reject('RPC Timed out.'); + }, 5000); + }); + } + + /** + * Register an RPC command + * @param {Function} command The command to execute + * @returns {*|void} + */ + register(command) { + if (!command || !command.name) { return; } + + logger.debug(`[RPCManager] Registering rpc command ${command.name}`); + const cmd = command(this); + cmd.pattern.topic = `dyno.bot`; + // cmd.pattern.cmd = `${cmd.pattern.cmd}.${this.clusterConfig.clusterId}`; + this.hemera.add(cmd.pattern, cmd.handler.bind(this)); + } +} + +module.exports = RPCManager; diff --git a/src/core/managers/WebhookManager.js b/src/core/managers/WebhookManager.js new file mode 100644 index 0000000..5f6fc8a --- /dev/null +++ b/src/core/managers/WebhookManager.js @@ -0,0 +1,93 @@ +'use strict'; + +const axios = require('axios'); + +/** + * @class WebhookManager + */ +class WebhookManager { + /** + * Manage webhook operations + * @param {Object} config The Dyno configuration + * @param {Dyno} dyno The Dyno instance + */ + constructor(dyno) { + this.dyno = dyno; + this.config = dyno.config; + this.client = dyno.client; + + this.avatarUrl = `${this.config.avatar}?r=${this.config.version}`; + + this.default = { + username: 'Dyno', + avatarURL: this.avatarUrl, + tts: false, + }; + } + + /** + * Get or create a channel webhook + * @param {Channel} channel Eris channel object + * @returns {Promise} + */ + async getOrCreate(channel) { + let id = (typeof channel === 'string') ? channel : channel.id || null; + if (!id) return Promise.reject(`Invalid channel or id.`); + + try { + const webhooks = await this.client.getChannelWebhooks(channel.id); + + let webhook = webhooks.find(hook => hook.name === 'Dyno'); + if (webhook) { + return Promise.resolve(webhook); + } + + const res = await axios.get(this.avatarUrl, { + headers: { Accept: 'image/*' }, + responseType: 'arraybuffer', + }).then(response => `data:${response.headers['content-type']};base64,${response.data.toString('base64')}`); + + webhook = await this.client.createChannelWebhook(channel.id, { + name: 'Dyno', + avatar: res, + }); + + return Promise.resolve(webhook); + } catch (err) { + return Promise.reject(err); + } + } + + /** + * Execute a webhook + * @param {Channel} channel Eris channel object + * @param {Object} options Webhook options to send + * @returns {Promise} + */ + async execute(channel, options, webhook) { + let avatarUrl = `https://cdn.discordapp.com/avatars/${this.dyno.user.id}/${this.dyno.user.avatar}.jpg`; + options.avatarURL = options.avatarURL || avatarUrl; + const content = Object.assign({}, this.default, options || {}); + + if (webhook) { + if (options.slack) { + delete options.slack; + return this.client.executeSlackWebhook(webhook.id, webhook.token, content); + } + return this.client.executeWebhook(webhook.id, webhook.token, content); + } + + try { + const webhook = await this.getOrCreate(channel); + if (options.slack) { + delete options.slack; + return this.client.executeSlackWebhook(webhook.id, webhook.token, content); + } + return this.client.executeWebhook(webhook.id, webhook.token, content); + } catch (err) { + return Promise.reject(err); + } + } +} + +module.exports = WebhookManager; diff --git a/src/core/matomo.js b/src/core/matomo.js new file mode 100644 index 0000000..fa6ac9d --- /dev/null +++ b/src/core/matomo.js @@ -0,0 +1,497 @@ +var MatomoTracker = require('matomo-tracker'); +var logger = require('./logger'); + +let matomoUsers, matomoGuilds, matomoMusicGuilds, matomoMusicUsers, actionLogBuffer, autoresponderBuffer, automodBuffer, commandBuffer, musicBuffer; + +const regionToCountryCodeMap = { + 'brazil': 'br', + 'vip-us-west': 'us', + 'vip-us-east': 'us', + 'us-west': 'us', + 'us-central': 'us', + 'us-east': 'us', + 'us-south': 'us', + 'japan': 'jp', + 'singapore': 'sg', + 'hongkong': 'hk', + 'vip-amsterdam': 'nl', + 'amsterdam': 'nl', + 'southafrica': 'za', + 'london': 'gb', + 'sydney': 'au', + 'frankfurt': 'DE', + 'russia': 'ru', + 'eu-central': 'pl', + 'eu-west': 'pt', +}; + +function initMatomo(dyno) { + matomoUsers = new MatomoTracker(4, 'http://10.12.0.69/piwik.php'); + matomoGuilds = new MatomoTracker(5, 'http://10.12.0.69/piwik.php'); + matomoMusicGuilds = new MatomoTracker(8, 'http://10.12.0.69/piwik.php'); + matomoMusicUsers = new MatomoTracker(9, 'http://10.12.0.69/piwik.php'); + + actionLogBuffer = { guildBuffer: [] }; + autoresponderBuffer = { guildBuffer: [] }; + automodBuffer = { guildBuffer: [] }; + musicBuffer = { guildBuffer: [], userBuffer: [] }; + commandBuffer = { guildBuffer: [], userBuffer: [] }; + + setInterval(() => { + if (dyno.globalConfig && dyno.globalConfig.enableMatomo && dyno.globalConfig.enableMatomo[dyno.config.stateName.toLowerCase()]) { return; } + if (!dyno.isReady) { return; } + + try { + const guildArr = []; + guildArr.push(...actionLogBuffer.guildBuffer); + actionLogBuffer.guildBuffer = []; + + guildArr.push(...autoresponderBuffer.guildBuffer); + autoresponderBuffer.guildBuffer = []; + + guildArr.push(...automodBuffer.guildBuffer); + automodBuffer.guildBuffer = []; + + guildArr.push(...commandBuffer.guildBuffer); + commandBuffer.guildBuffer = []; + + const userArr = []; + + userArr.push(...commandBuffer.userBuffer); + commandBuffer.userBuffer = []; + + let start = new Date().getTime(); + + if (guildArr.length > 0) { + matomoGuilds.trackBulk(guildArr, () => { + const end = new Date().getTime(); + logger.debug(`Flushed Matomo guild buffer. Took ${Math.abs(start - end)}ms for ${guildArr.length} events`); + }); + } + + start = new Date().getTime(); + + if (userArr.length > 0) { + matomoUsers.trackBulk(userArr, () => { + const end = new Date().getTime(); + logger.debug(`Flushed Matomo user buffer. Took ${Math.abs(start - end)}ms for ${userArr.length} events`); + }); + } + + const musicGuildArr = []; + musicGuildArr.push(...musicBuffer.guildBuffer); + musicBuffer.guildBuffer = []; + + + const musicUserArr = []; + musicUserArr.push(...musicBuffer.userBuffer); + musicBuffer.userBuffer = []; + + start = new Date().getTime(); + + if (musicGuildArr.length > 0) { + matomoMusicGuilds.trackBulk(musicGuildArr, () => { + const end = new Date().getTime(); + logger.debug(`Flushed Matomo music guild buffer. Took ${Math.abs(start - end)}ms for ${musicGuildArr.length} events`); + }); + } + + start = new Date().getTime(); + + if (musicUserArr.length > 0) { + matomoMusicUsers.trackBulk(musicUserArr, () => { + const end = new Date().getTime(); + logger.debug(`Flushed Matomo music user buffer. Took ${Math.abs(start - end)}ms for ${musicUserArr.length} events`); + }); + } + } catch (err) { + logger.error(err); + } + }, 10000); + + setInterval(() => { + if (!dyno.isReady) { return; } + if (dyno.globalConfig && dyno.globalConfig.enableMatomo && dyno.globalConfig.enableMatomo[dyno.config.stateName.toLowerCase()]) { return; } + + dyno.players.forEach((p) => { + if (!p.voiceChannel || !p.voiceChannel.voiceMembers || !p.playing) { return; } + const guild = p.voiceChannel.guild; + + const country = regionToCountryCodeMap[guild.region] || 'aq'; + musicBuffer.guildBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/session`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'Refresh', + uid: guild.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + }), + }); + + p.voiceChannel.voiceMembers.forEach((m) => { + musicBuffer.userBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/user/session`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'Refresh', + uid: m.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['User ID', m.id], + }), + }); + }); + }); + }, 14 * 1000 * 60); + + matomoUsers.on('error', (err) => { + logger.error(err); + }); + + matomoGuilds.on('error', (err) => { + logger.error(err); + }); + + matomoMusicGuilds.on('error', (err) => { + logger.error(err); + }); + + matomoMusicUsers.on('error', (err) => { + logger.error(err); + }); + + dyno.internalEvents.on('music', ({ type, guild, channel, user, search, trackInfo }) => { + if (dyno.globalConfig && dyno.globalConfig.enableMatomo && dyno.globalConfig.enableMatomo[dyno.config.stateName.toLowerCase()]) { return; } + let guildEvent; + let userEvent; + let player; + + const country = regionToCountryCodeMap[guild.region] || 'aq'; + switch (type) { + case 'start': + guildEvent = { + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/session/start`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'Start', + uid: guild.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Server', dyno.config.stateName], + }), + }; + + player = dyno.players.get(guild.id); + if (!player || !player.playing || !player.voiceChannel || !player.voiceChannel.voiceMembers) { + player.voiceChannel.voiceMembers.forEach((m) => { + musicBuffer.userBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/user/session/join`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'Start', + uid: m.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['User ID', m.id], + 3: ['Server', dyno.config.stateName], + }), + }); + }); + } + + break; + case 'end': + guildEvent = { + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/session/end`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'End', + uid: guild.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Server', dyno.config.stateName], + }), + }; + + player = dyno.players.get(guild.id); + if (!player || !player.voiceChannel || !player.voiceChannel.voiceMembers) { + break; + } + + player.voiceChannel.voiceMembers.forEach((m) => { + musicBuffer.userBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/user/session/leave`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'End', + uid: m.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['User ID', m.id], + 3: ['Server', dyno.config.stateName], + }), + }); + }); + break; + case 'join': + userEvent = { + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/user/session/join`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'Start', + uid: user.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['User ID', user.id], + 3: ['Server', dyno.config.stateName], + }), + }; + break; + case 'leave': + userEvent = { + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/user/session/leave`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'End', + uid: user.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['User ID', user.id], + 3: ['Server', dyno.config.stateName], + }), + }; + break; + case 'changeSong': + guildEvent = { + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/session/changeSong`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'ChangeSong', + uid: guild.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Server', dyno.config.stateName], + }), + }; + break; + case 'playSong': + guildEvent = { + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/session/playSong`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'PlaySong', + uid: guild.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Server', dyno.config.stateName], + }), + }; + break; + case 'search': + guildEvent = { + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/session/search`, + ua: 'Node.js', + search: search, + search_count: 1, + uid: guild.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Params', search], + 3: ['Server', dyno.config.stateName], + }), + }; + break; + case 'skip': + guildEvent = { + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/music/session/skip`, + action_name: 'Music', + ua: 'Node.js', + e_c: 'Music', + e_a: 'Session', + e_n: 'Skip', + uid: guild.id, + country, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Server', dyno.config.stateName], + }), + }; + break; + } + + if (guildEvent) { + musicBuffer.guildBuffer.push(guildEvent); + } + + if (userEvent) { + musicBuffer.userBuffer.push(userEvent); + } + }); + + dyno.internalEvents.on('actionlog', ({ type, guild }) => { + if (dyno.globalConfig && dyno.globalConfig.enableMatomo && dyno.globalConfig.enableMatomo[dyno.config.stateName.toLowerCase()]) { return; } + if (!matomoUsers || !matomoGuilds) { + return; + } + + if (type === 'commands') { + return; + } + + actionLogBuffer.guildBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/actionlog/${type}`, + action_name: 'ActionLog', + ua: 'Node.js', + e_c: 'AutomatedAction', + e_a: 'ActionLog', + e_n: type, + uid: guild.id, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Server', dyno.config.stateName], + }), + }); + }); + + dyno.internalEvents.on('autoresponder', ({ type, guild }) => { + if (dyno.globalConfig && dyno.globalConfig.enableMatomo && dyno.globalConfig.enableMatomo[dyno.config.stateName.toLowerCase()]) { return; } + if (!matomoUsers || !matomoGuilds) { + return; + } + + autoresponderBuffer.guildBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/autoresponder/${type}`, + action_name: 'AutoResponder', + ua: 'Node.js', + e_c: 'AutomatedAction', + e_a: 'AutoResponder', + e_n: type, + uid: guild.id, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Server', dyno.config.stateName], + }), + }); + }); + + dyno.internalEvents.on('automod', ({ type, guild }) => { + if (dyno.globalConfig && dyno.globalConfig.enableMatomo && dyno.globalConfig.enableMatomo[dyno.config.stateName.toLowerCase()]) { return; } + if (!matomoUsers || !matomoGuilds) { + return; + } + + automodBuffer.guildBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/modules/automod/${type}`, + action_name: 'AutoMod', + ua: 'Node.js', + e_c: 'AutomatedAction', + e_a: 'AutoMod', + e_n: type, + uid: guild.id, + _cvar: JSON.stringify({ + 1: ['Guild ID', guild.id], + 2: ['Server', dyno.config.stateName], + }), + }); + }); + + dyno.commands.on('command', ({ command, message, guildConfig, args, time, isServerAdmin, isServerMod }) => { + if (dyno.globalConfig && dyno.globalConfig.enableMatomo && dyno.globalConfig.enableMatomo[dyno.config.stateName.toLowerCase()]) { return; } + if (!matomoUsers || !matomoGuilds) { + return; + } + + const user = message.author; + const channel = message.channel; + const guild = channel.guild; + commandBuffer.userBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/commands/${command.name}/${user.id}`, + action_name: 'CommandUsed', + ua: 'Node.js', + e_c: 'Command', + e_a: command.module || command.group, + e_n: command.name, + gt_ms: time, + uid: user.id, + _cvar: JSON.stringify({ + 1: ['Command Name', command.name], + 2: ['Arguments', args.join(' ')], + 3: ['Guild ID', guild.id], + 4: ['Server', dyno.config.stateName], + 5: ['User ID', user.id], + }), + dimension2: (isServerAdmin || isServerMod) ? 'true' : 'false', + }); + + commandBuffer.userBuffer.push({ + token_auth: dyno.globalConfig.matomoTokenAuth || 'ee9e3342c6a8f02ea0e1278060dd3db5', + url: `/commands/${command.name}/${user.id}`, + action_name: 'CommandUsed', + ua: 'Node.js', + e_c: 'Command', + e_a: command.module || command.group, + e_n: command.name, + gt_ms: time, + uid: guild.id, + _cvar: JSON.stringify({ + 1: ['Command Name', command.name], + 2: ['Arguments', args.join(' ')], + 3: ['Guild ID', guild.id], + 4: ['Server', dyno.config.stateName], + 5: ['User ID', user.id], + }), + }); + }); +} + +module.exports = { + initMatomo, +}; diff --git a/src/core/metrics.js b/src/core/metrics.js new file mode 100644 index 0000000..82ccebd --- /dev/null +++ b/src/core/metrics.js @@ -0,0 +1,182 @@ +const express = require('express'); +const cluster = require('cluster'); +const prom = require('prom-client'); +const config = require('./config'); +const logger = require('./logger'); +const server = express(); +const Registry = prom.Registry; +const AggregatorRegistry = prom.AggregatorRegistry; + +const aggregatorRegistry = new AggregatorRegistry(); +const cmRegister = new Registry(); + +if (cluster.isMaster) { + server.get('/metrics', (req, res) => { + aggregatorRegistry.clusterMetrics((err, metrics) => { + if (err) logger.error(err); + res.set('Content-Type', aggregatorRegistry.contentType); + res.send(metrics); + }); + }); + + server.get('/cm_metrics', (req, res) => { + res.set('Content-Type', cmRegister.contentType); + res.end(cmRegister.metrics()); + }); + + cmRegister.setDefaultLabels({ server: config.stateName.toLowerCase() }); + prom.collectDefaultMetrics({ register: cmRegister, prefix: 'dyno_cm_' }); + server.listen(3001); +} else { + const defaultLabels = { clusterId: process.env.clusterId, server: config.stateName.toLowerCase() }; + prom.register.setDefaultLabels(defaultLabels); + prom.collectDefaultMetrics({ prefix: 'dyno_app_' }); + + const messagesCounter = new prom.Counter({ + name: 'dyno_app_messages_sent', + help: 'Counts messages sent (type = dm|normal|webhook)', + labelNames: ['type'], + }); + const helpSentCounter = new prom.Counter({ + name: 'dyno_app_help_sent', + help: 'Counts helps sent', + }); + const helpFailedCounter = new prom.Counter({ + name: 'dyno_app_help_failed', + help: 'Counts helps failed', + }); + const guildsCarbon = new prom.Gauge({ + name: 'dyno_app_guilds_carbon', + help: 'Guild count for Dyno', + }); + const guildEvents = new prom.Counter({ + name: 'dyno_app_guild_events', + help: 'Guild events counter (type = create, delete, etc)', + labelNames: ['type'], + }); + const guildCounts = new prom.Gauge({ + name: 'dyno_app_guild_count', + help: 'Guild count based on cluster id', + }); + const userCounts = new prom.Gauge({ + name: 'dyno_app_user_count', + help: 'User count based on cluster id', + }); + const gatewayEvents = new prom.Gauge({ + name: 'dyno_app_gateway_events', + help: 'GW Event counter (type = event type)', + labelNames: ['type'], + }); + const messageEvents = new prom.Counter({ + name: 'dyno_app_message_events', + help: 'Message events counter (type = create, delete, etc)', + labelNames: ['type'], + }); + const discordShard = new prom.Counter({ + name: 'dyno_app_discord_shard', + help: 'Discord shard status (type = connect, disconnect, resume, etc)', + labelNames: ['type'], + }); + const commandSuccess = new prom.Counter({ + name: 'dyno_app_command_success', + help: 'Command success counter (group = cmd group, name = cmd name)', + labelNames: ['group', 'name'], + }); + const commandError = new prom.Counter({ + name: 'dyno_app_command_error', + help: 'Command error counter (group = cmd group, name = cmd name)', + labelNames: ['group', 'name'], + }); + const commandTimings = new prom.Histogram({ + name: 'dyno_app_command_time', + help: 'Command timing histogram (group = cmd group, name = cmd name)', + labelNames: ['group', 'name'], + buckets: [100, 200, 300, 500, 800, 1000, 5000], + }); + const purgeSuccessCounter = new prom.Counter({ + name: 'dyno_app_purge_success', + help: 'Counts successful purges', + }); + const purgeFailedCounter = new prom.Counter({ + name: 'dyno_app_purge_failed', + help: 'Counts failed purges', + }); + const eventLoopBlockCounter = new prom.Counter({ + name: 'dyno_app_node_blocked', + help: 'Counts node event loop blocks', + }); + const musicPlaylists = new prom.Counter({ + name: 'dyno_app_music_playlists', + help: 'Counts music playlists', + }); + const musicAdds = new prom.Counter({ + name: 'dyno_app_music_adds', + help: 'Counts music adds', + }); + const voiceTotals = new prom.Gauge({ + name: 'dyno_app_voice_total', + help: 'Voice totals gauge', + labelNames: ['state'], + }); + const voicePlaying = new prom.Gauge({ + name: 'dyno_app_voice_playing', + help: 'Voice playing gauge', + labelNames: ['state'], + }); + + // Music module metrics + const musicModuleMetrics = [ + new prom.Counter({ + name: 'dyno_app_music_total_user_listen_time', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_total_playing_time', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_song_ends', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_partial_song_ends', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_unique_session_joins', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_disconnects', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_joins', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_leaves', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_plays', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_search', + help: 'Music module metrics', + }), + new prom.Counter({ + name: 'dyno_app_music_skips', + help: 'Music module metrics', + }), + new prom.Summary({ + name: 'dyno_app_music_user_session_summary', + help: 'Music module metrics', + }), + new prom.Summary({ + name: 'dyno_app_music_session_summary', + help: 'Music module metrics', + }), + ]; +} diff --git a/src/core/processManager/Manager.js b/src/core/processManager/Manager.js new file mode 100644 index 0000000..7d84535 --- /dev/null +++ b/src/core/processManager/Manager.js @@ -0,0 +1,146 @@ +const cluster = require('cluster'); +const config = require('../config'); +const logger = require('../logger'); +const { Collection } = require('@dyno.gg/dyno-core'); +const { Client, Server, LogServer } = require('../rpc'); +const Process = require('./Process'); + +/** + * @class Manager + */ +class Manager { + constructor() { + this.processes = new Collection(); + + process.on('uncaughtException', this.handleException.bind(this)); + process.on('unhandledRejection', this.handleRejection.bind(this)); + + cluster.on('exit', this.handleExit.bind(this)); + + cluster.setupMaster({ + silent: true, + }); + + this.logServer = new LogServer(); + this.logServer.init(5025); + + this.clusterManager = this.createManager(); + + let methods = { + create: this.create.bind(this), + delete: this.delete.bind(this), + list: this.list.bind(this), + restart: this.restart.bind(this), + restartManager: this.restartManager.bind(this), + }; + + this.client = new Client(config.rpcHost || 'localhost', 5052); + + this.server = new Server(); + this.server.init(config.rpcHost || 'localhost', 5050, methods); + } + + handleRejection(reason, p) { + try { + console.error('Unhandled rejection at: Promise ', p, 'reason: ', reason); // eslint-disable-line + } catch (err) { + console.error(reason); // eslint-disable-line + } + } + + handleException(err) { + if (!err || (typeof err === 'string' && !err.length)) { + return console.error('An undefined exception occurred.'); // eslint-disable-line + } + + console.error(err); // eslint-disable-line + } + + createManager() { + const proc = new Process(this, { + manager: true, + }); + this.logServer.hook(proc); + + return proc; + } + + createProcess(options) { + const process = new Process(this, options); + this.processes.set(process.id, process); + this.logServer.hook(process); + + return process; + } + + getProcess(worker) { + return this.processes.find(s => s.pid === worker.process.pid || s._pid === worker.process.pid); + } + + handleExit(worker, code, signal) { + const process = this.getProcess(worker); + + if (signal && signal === 'SIGTERM') return; + if (!process) return; + + this.client.request('processExit', { process, code, signal }); + + // Restart worker + process.restartWorker().then(() => { + this.client.request('processReady', { process }); + }); + } + + create(payload, cb) { + const options = payload && (payload.cluster || {}); + const process = this.createProcess(options); + return cb(null, process); + } + + delete(payload, cb) { + if (!payload.id) { + return cb('Missing ID'); + } + const process = this.processes.get(payload.id); + if (!process) { + return cb(`Process ${payload.id} not found.`); + } + try { + process.worker.kill('SIGTERM'); + this.processes.delete(payload.id); + return cb(null, 'OK'); + } catch (err) { + logger.error(err); + return cb(err); + } + } + + list(payload, cb) { + return cb(null, [...this.processes.values()]); + } + + async restart(payload, cb) { + if (!payload.id) { + return cb('Missing ID'); + } + + const process = this.processes.get(payload.id); + if (!process) { + return cb(`Process ${payload.id} not found.`); + } + try { + const proc = await process.restartWorker(true); + return cb(null, proc); + } catch (err) { + logger.error(err); + return cb(err); + } + } + + restartManager(payload, cb) { + this.clusterManager.restartWorker(); + return cb(null, 'OK'); + } +} + +module.exports = Manager; diff --git a/src/core/processManager/Process.js b/src/core/processManager/Process.js new file mode 100644 index 0000000..2c6ae14 --- /dev/null +++ b/src/core/processManager/Process.js @@ -0,0 +1,99 @@ +const cluster = require('cluster'); +const uuid = require('uuid/v4'); + +var EventEmitter; + +try { + EventEmitter = require('eventemitter3'); +} catch (e) { + EventEmitter = require('events'); +} + +/** + * @class Process + * @extends {EventEmitter} + */ +class Process extends EventEmitter { + /** + * Representation of a process + * + * @prop {Number} id Process ID + * @prop {Object} worker The worker + * @prop {Object} process The worker process + * @prop {Number} pid The worker process ID + * @prop {Number} port The process RPC port + */ + constructor(manager, options = {}) { + super(); + + this.id = uuid(); + this.pid = null; + this.port = null; + this.options = options || {}; + this.createdAt = Date.now(); + + if (options.manager) { + this.manager = true; + this.port = 5052; + } + + if (options.cluster && options.cluster.id) { + this.port = 30000 + parseInt(options.cluster.id, 10); + } + + this.worker = this.createWorker(options.awaitReady); + this.process = this.worker.process; + } + + createWorker(awaitReady = false) { + const worker = cluster.fork( + Object.assign({ + awaitReady: awaitReady, + uuid: this.id, + }, this.options) + ); + + this.pid = worker.process.pid; + this.createdAt = Date.now(); + + process.nextTick(() => { + this._readyListener = this.ready.bind(this); + worker.on('message', this._readyListener); + }); + + return worker; + } + + restartWorker(awaitReady = false) { + const worker = this.createWorker(awaitReady); + const oldWorker = this.worker; + const createdAt = Date.now(); + this._pid = worker.process.pid; + + return new Promise(resolve => { + this.on('ready', () => { + if (this.worker) { + oldWorker.kill('SIGTERM'); + } + + process.nextTick(() => { + this.worker = worker; + this.process = worker.process; + this.pid = worker.process.pid; + this.createdAt = createdAt; + return resolve(this); + }); + }); + }); + } + + ready(message) { + if (!message) return; + if (message === 'ready' || message.op === 'ready') { + this.worker.removeListener('ready', this._readyListener); + this.emit('ready'); + } + } +} + +module.exports = Process; diff --git a/src/core/redis.js b/src/core/redis.js new file mode 100644 index 0000000..e045166 --- /dev/null +++ b/src/core/redis.js @@ -0,0 +1,36 @@ +'use strict'; + +const Redis = require('ioredis'); +const logger = require('./logger'); + +async function connect() { + return new Promise((resolve, reject) => { + const client = new Redis({ + name: 'master', + sentinels: [ + { host: '10.12.0.55', port: 26379 }, + { host: '10.12.0.56', port: 26379 }, + { host: '10.12.0.57', port: 26379 }, + { host: '10.12.0.58', port: 26379 }, + ], + }); + + const rejectFunc = (err) => { + reject(err); + }; + + client.on('ready', () => { + logger.info('Connected to redis.'); + client.removeListener('error', rejectFunc); + resolve(client); + }); + + client.once('error', rejectFunc); + + client.on('error', err => { + logger.error(err); + }); + }); +} + +module.exports = { connect }; diff --git a/src/core/rpc/Client.js b/src/core/rpc/Client.js new file mode 100644 index 0000000..f586f6f --- /dev/null +++ b/src/core/rpc/Client.js @@ -0,0 +1,14 @@ +const jayson = require('jayson/promise'); + +class Client { + constructor(host, port) { + this.client = jayson.client.http({ + host, + port, + }); + + return this.client; + } +} + +module.exports = Client; diff --git a/src/core/rpc/LogServer.js b/src/core/rpc/LogServer.js new file mode 100644 index 0000000..8852920 --- /dev/null +++ b/src/core/rpc/LogServer.js @@ -0,0 +1,44 @@ +/* eslint-disable no-unused-vars */ +const WebSocket = require('ws'); +const jayson = require('jayson'); + +class LogServer { + init(port) { + this.wss = new WebSocket.Server({ port }); + } + + hook(proc) { + proc.process.stdout.pipe(process.stdout); + proc.process.stdout.on('data', (data) => { + this._broadcastLog(data, proc, 'stdout'); + }); + + proc.process.stderr.pipe(process.stderr); + proc.process.stderr.on('data', (data) => { + this._broadcastLog(data, proc, 'stderr'); + }); + } + + _broadcastLog(data, proc, stream) { + const msg = data.toString(); + const payload = { + pid: proc.pid, + createdAt: proc.createdAt, + cm: !!proc.manager, + msg, + stream, + }; + + if (proc.options && proc.options.id !== undefined) { + payload.cid = proc.options.id; + } + + this.wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(payload)); + } + }); + } +} + +module.exports = LogServer; diff --git a/src/core/rpc/Server.js b/src/core/rpc/Server.js new file mode 100644 index 0000000..06b788e --- /dev/null +++ b/src/core/rpc/Server.js @@ -0,0 +1,13 @@ +/* eslint-disable no-unused-vars */ +const jayson = require('jayson'); + +class Server { + init(host, port, methods) { + this.host = host; + this.port = port; + this.server = jayson.server(methods); + this.server.http().listen(this.port, this.host); + } +} + +module.exports = Server; diff --git a/src/core/rpc/index.js b/src/core/rpc/index.js new file mode 100644 index 0000000..35b53e3 --- /dev/null +++ b/src/core/rpc/index.js @@ -0,0 +1,3 @@ +exports.Client = require('./Client'); +exports.Server = require('./Server'); +exports.LogServer = require('./LogServer'); \ No newline at end of file diff --git a/src/core/statsd.js b/src/core/statsd.js new file mode 100644 index 0000000..cb36731 --- /dev/null +++ b/src/core/statsd.js @@ -0,0 +1,17 @@ +'use strict'; + +const StatsD = require('hot-shots'); +const logger = require('./logger'); +const config = require('./config'); + +const client = new StatsD({ + host: config.statsd.host, + port: config.statsd.port, + prefix: config.statsd.prefix, +}); + +client.socket.on('error', err => { + logger.error('Error in socket: ', err); +}); + +module.exports = client; diff --git a/src/core/transports/winston-sentry.js b/src/core/transports/winston-sentry.js new file mode 100644 index 0000000..333bbbb --- /dev/null +++ b/src/core/transports/winston-sentry.js @@ -0,0 +1,121 @@ +/* eslint-disable */ +const util = require('util'); +const Raven = require('raven'); +const winston = require('winston'); +const config = require('../config'); + +var Sentry = winston.transports.Sentry = function (options) { + winston.Transport.call(this, { level: options.level }); + + // Default options + this.defaults = { + dsn: '', + logger: 'root', + autoBreadcrumbs: true, + captureUnhandledRejections: false, + levelsMap: { + silly: 'debug', + verbose: 'debug', + info: 'info', + debug: 'debug', + warn: 'warning', + error: 'error', + }, + tags: {}, + extra: {}, + }; + + // For backward compatibility with deprecated `globalTags` option + options.tags = options.tags || options.globalTags; + + this.options = Object.assign({}, this.defaults, options); + + Raven.config(this.options.dsn, this.options); + + // Handle errors + Raven.on('error', function (error) { + var message = 'Cannot talk to sentry.'; + if (error && error.reason) { + message += ' Reason: ' + error.reason; + } + console.error(message); // eslint-disable-line + }); +}; + +// +// Inherit from `winston.Transport` so you can take advantage +// of the base functionality and `.handleExceptions()`. +// +util.inherits(Sentry, winston.Transport); + +// +// Expose the name of this Transport on the prototype +Sentry.prototype.name = 'sentry'; +// + +Sentry.prototype.log = function (level, msg, meta, callback) { + level = this.options.levelsMap[level]; + meta = meta || {}; + + var extraData = Object.assign({}, meta), + tags = extraData.tags; + delete extraData.tags; + + if (extraData.guild && typeof extraData.guild !== 'string') { + if (extraData.guild.shard) { + extraData.shard = extraData.guild.shard.id; + } + extraData.guild = extraData.guild.id; + } + + if (config.clusterId || config.shardId) { + extraData.clusterId = config.clusterId || config.shardId; + extraData.clusterId = extraData.clusterId.toString(); + } + extraData.pid = process.pid; + + if (config.firstShardId) extraData.firstShardId = config.firstShardId.toString(); + if (config.lastShardId) extraData.lastShardId = config.lastShardId.toString(); + + var extra = { + level: level, + extra: extraData, + tags: tags, + }; + + if (extraData.request) { + extra.request = extraData.request; + delete extraData.request; + } + + if (extraData.user) { + extra.user = extraData.user; + delete extraData.user; + } + + try { + if (level === 'error') { + // Support exceptions logging + if (meta instanceof Error) { + if (msg === '') { + msg = meta; + } else { + meta.message = msg + '. cause: ' + meta.message; + msg = meta; + } + } + + Raven.captureException(msg, extra, function () { + callback(null, true); + }); + } else { + Raven.captureMessage(msg, extra, function () { + callback(null, true); + }); + } + } catch (err) { + console.error(err); // eslint-disable-line + } +}; + +module.exports = Sentry; diff --git a/src/core/utils/Diagnostics.js b/src/core/utils/Diagnostics.js new file mode 100644 index 0000000..c83c036 --- /dev/null +++ b/src/core/utils/Diagnostics.js @@ -0,0 +1,201 @@ +class Diagnostics { + constructor(dyno) { + this.dyno = dyno; + } + + async diagnose(guildId, name) { + const guild = this.dyno.client.guilds.get(guildId); + const guildConfig = await this.dyno.guilds.getOrFetch(guildId); + + if (!guild || !guildConfig) { + return `I can't find that guild`; + } + + name = name.toLowerCase(); + + const command = this.dyno.commands.find((c) => c.name === name && c.permissions !== 'admin'); + + if (command) { + return this.diagnoseCommand(command, guild, guildConfig); + } + + const mod = this.dyno.modules.find((m) => !m.core && + (m.module.toLowerCase() === name || + (m.friendlyName && m.friendlyName.toLowerCase() === name))); + + if (mod) { + return this.diagnoseModule(mod, guild, guildConfig); + } + + return `I can't find a command or module by that name.`; + } + + diagnoseDefault(guild, guildConfig, perms) { + const permissions = ['readMessages', 'sendMessages', 'embedLinks', 'externalEmojis']; + const diagnosis = { info: [], issues: [] }; + + diagnosis.info.push(`The prefix for this server is \`${guildConfig.prefix || '?'}\``); + + if (guildConfig.modonly) { + diagnosis.info.push(`Make commands mod-only is enabled.`); + } + + const clientMember = guild.members.get(this.dyno.client.user.id); + + if (!clientMember.roles || !clientMember.roles.length) { + diagnosis.issues.push(`Dyno does not have a role, it should have atleast the Dyno role.`); + diagnosis.issues.push(`You can fix this by authorizing the bot here: https://dyno.gg/invite`); + } + + for (const perm of permissions) { + if (!clientMember.permission.has(perm)) { + const permission = this.dyno.config.permissionsMap[perm]; + diagnosis.issues.push(`The Dyno role is missing the ${permission} permission.`); + } + } + + if (perms && clientMember.roles && clientMember.roles.length) { + const highestRole = this.dyno.utils.highestRole(guild, clientMember); + if (highestRole && highestRole.position === 1) { + diagnosis.issues.push(`The Dyno role has not been moved, move it up in the list above other user roles.`); + } + } + + return diagnosis; + } + + // tslint:disable-next-line:cyclomatic-complexity + diagnoseCommand(command, guild, guildConfig) { + const globalConfig = this.dyno.globalConfig; + const module = command.module || command.group; + const diagnosis = this.diagnoseDefault(guild, guildConfig, command.requiredPermissions) || + { info: [], issues: [] }; + + const name = command.name; + + if (command.permissions === 'serverAdmin') { + diagnosis.info.push(`The command requires Manage Server permissions.`); + } + + if (command.permissions === 'serverMod') { + diagnosis.info.push(`The command requires Moderator permissions.`); + } + + if (globalConfig && globalConfig.commands && globalConfig.commands.hasOwnProperty(name) && + globalConfig.commands[name] === false) { + diagnosis.issues.push(`The command is globally disabled in Dyno by the developer.`); + } + + if (globalConfig && globalConfig.modules && globalConfig.modules.hasOwnProperty(module) && + globalConfig.modules[module] === false) { + diagnosis.issues.push(`The ${module} module is globally disabled in Dyno by the developer.`); + } + + if (guildConfig.commands[name] === false) { + diagnosis.issues.push(`The command is disabled.`); + } + + if (typeof guildConfig.commands[name] !== 'boolean' && guildConfig.commands[name].enabled === false) { + diagnosis.issues.push(`The command is disabled.`); + } + + if (this.dyno.modules.has(module) && + (guildConfig.modules.hasOwnProperty(module) && guildConfig.modules[module] === false)) { + diagnosis.issues.push(`The ${module} module is disabled. Enable it to use this command.`); + } else if (this.dyno.modules.has(module)) { + diagnosis.info.push(`The command uses the ${module} module, which is enabled.`); + } + + if (command.requiredPermissions) { + const clientMember = guild.members.get(this.dyno.client.user.id); + for (const perm of command.requiredPermissions) { + if (!clientMember.permission.has(perm)) { + const permission = this.dyno.config.permissionsMap[perm]; + diagnosis.issues.push(`The Dyno role is missing the **${permission}** permission.`); + } + } + } + + const embed = { + color: null, + description: null, + title: `Diagnosis: ${name}`, + fields: [], + }; + + if (diagnosis.info.length) { + embed.fields.push({ name: 'Info', value: diagnosis.info.join('\n'), inline: false }); + } + + if (diagnosis.issues.length) { + embed.color = this.dyno.utils.getColor('orange'); + embed.fields.push({ name: 'Issues', value: diagnosis.issues.join('\n'), inline: false }); + } else { + embed.color = this.dyno.utils.getColor('green'); + embed.description = 'There are no apparent issues with this command'; + } + + return { embed }; + } + + diagnoseModule(module, guild, guildConfig) { + const globalConfig = this.dyno.globalConfig; + let diagnosis = this.diagnoseDefault(guild, guildConfig, module.perms) || { info: [], issues: [] }; + + const name = module.module || module.name; + + if (globalConfig && globalConfig.modules && globalConfig.modules.hasOwnProperty(name) && + globalConfig.modules[name] === false) { + diagnosis.issues.push(`The module is globally disabled in Dyno by the developer.`); + } + + if (guildConfig.modules.hasOwnProperty(name) && guildConfig.modules[name] === false) { + diagnosis.issues.push(`The module is disabled on this server.`); + } else { + diagnosis.info.push(`The module is enabled on this server.`); + } + + if (module.permissions) { + const clientMember = guild.members.get(this.dyno.client.user.id); + for (const perm of module.permissions) { + if (!clientMember.permission.has(perm)) { + const permission = this.dyno.config.permissionsMap[perm]; + diagnosis.issues.push(`The Dyno role is missing the **${permission}** permission.`); + } + } + } + + if (module.diagnose) { + diagnosis = module.diagnose({ guild, guildConfig, diagnosis }); + } + + const embed = { + color: null, + description: null, + title: `Diagnosis: ${name}`, + fields: [], + footer: { + // tslint:disable-next-line:max-line-length + text: `${this.dyno.config.stateName} | Cluster ${this.dyno.options.clusterId} | Shard ${guild.shard.id} | ID ${guild.id}`, + }, + }; + + if (diagnosis) { + if (diagnosis.info.length) { + embed.fields.push({ name: 'Info', value: diagnosis.info.join('\n'), inline: false }); + } + + if (diagnosis.issues.length) { + embed.color = this.dyno.utils.getColor('orange'); + embed.fields.push({ name: 'Issues', value: diagnosis.issues.join('\n'), inline: false }); + } else { + embed.color = this.dyno.utils.getColor('green'); + embed.description = 'There are no apparent issues with this module'; + } + } + + return { embed }; + } +} + +module.exports = Diagnostics; diff --git a/src/events/channelCreate.js b/src/events/channelCreate.js new file mode 100644 index 0000000..3bbeeed --- /dev/null +++ b/src/events/channelCreate.js @@ -0,0 +1,17 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function channelCreate(dispatcher, channel) { + if (!dispatcher.dyno.isReady || !channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(channel.guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(channel.guild.id).then(guildConfig => resolve({ + channel: channel, + guild: channel.guild, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/channelDelete.js b/src/events/channelDelete.js new file mode 100644 index 0000000..7bc38bf --- /dev/null +++ b/src/events/channelDelete.js @@ -0,0 +1,17 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function channelDelete(dispatcher, channel) { + if (!dispatcher.dyno.isReady || !channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(channel.guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(channel.guild.id).then(guildConfig => resolve({ + channel: channel, + guild: channel.guild, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/guildBanAdd.js b/src/events/guildBanAdd.js new file mode 100644 index 0000000..a0c4528 --- /dev/null +++ b/src/events/guildBanAdd.js @@ -0,0 +1,17 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function guildBanAdd(dispatcher, guild, member) { + if (!dispatcher.dyno.isReady || !guild || !member) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(guild.id).then(guildConfig => resolve({ + guild: guild, + member: member, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/guildBanRemove.js b/src/events/guildBanRemove.js new file mode 100644 index 0000000..50deb08 --- /dev/null +++ b/src/events/guildBanRemove.js @@ -0,0 +1,17 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function guildBanRemove(dispatcher, guild, member) { + if (!dispatcher.dyno.isReady || !guild || !member) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(guild.id).then(guildConfig => resolve({ + guild: guild, + member: member, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js new file mode 100644 index 0000000..678c574 --- /dev/null +++ b/src/events/guildMemberAdd.js @@ -0,0 +1,17 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function guildMemberAdd(dispatcher, guild, member) { + if (!dispatcher.dyno.isReady || !guild || !member) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(guild.id).then(guildConfig => resolve({ + guild: guild, + member: member, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.js new file mode 100644 index 0000000..dd5d9b3 --- /dev/null +++ b/src/events/guildMemberRemove.js @@ -0,0 +1,17 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function guildMemberRemove(dispatcher, guild, member) { + if (!dispatcher.dyno.isReady || !guild || !member) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(guild.id).then(guildConfig => resolve({ + guild: guild, + member: member, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/guildMemberUpdate.js b/src/events/guildMemberUpdate.js new file mode 100644 index 0000000..1fe6905 --- /dev/null +++ b/src/events/guildMemberUpdate.js @@ -0,0 +1,18 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function guildMemberUpdate(dispatcher, guild, member, oldMember) { + if (!dispatcher.dyno.isReady || !guild || !member) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(guild.id).then(guildConfig => resolve({ + guild: guild, + member: member, + oldMember: oldMember, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/guildRoleCreate.js b/src/events/guildRoleCreate.js new file mode 100644 index 0000000..29eb4b8 --- /dev/null +++ b/src/events/guildRoleCreate.js @@ -0,0 +1,17 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function guildRoleCreate(dispatcher, guild, role) { + if (!dispatcher.dyno.isReady || !guild || !role) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(guild.id).then(guildConfig => resolve({ + guild: guild, + role: role, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/guildRoleDelete.js b/src/events/guildRoleDelete.js new file mode 100644 index 0000000..6e2d12a --- /dev/null +++ b/src/events/guildRoleDelete.js @@ -0,0 +1,17 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function guildRoleDelete(dispatcher, guild, role) { + if (!dispatcher.dyno.isReady || !guild || !role) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(guild.id).then(guildConfig => resolve({ + guild: guild, + role: role, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/guildRoleUpdate.js b/src/events/guildRoleUpdate.js new file mode 100644 index 0000000..6d70d47 --- /dev/null +++ b/src/events/guildRoleUpdate.js @@ -0,0 +1,18 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function guildRoleUpdate(dispatcher, guild, role, oldRole) { + if (!dispatcher.dyno.isReady || !guild || !role) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(guild.id).then(guildConfig => resolve({ + guild: guild, + role: role, + oldRole: oldRole, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js new file mode 100644 index 0000000..a235bf9 --- /dev/null +++ b/src/events/messageCreate.js @@ -0,0 +1,36 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function messageCreate(dispatcher, message) { + if (!dispatcher.dyno.isReady || (message.author && message.author.bot)) return Promise.reject(); + if (!message.channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(message.channel.guild, dispatcher.config)) return Promise.reject(); + + const guildConfig = dispatcher.dyno.guilds.get(message.channel.guild.id); + if (guildConfig) { + return Promise.resolve({ + message: message, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + }); + } + + return dispatcher.dyno.guilds.getOrFetch(message.channel.guild.id).then(guildConfig => { // eslint-disable-line + const guild = message.channel.guild; + if (!guild.cacheMessages && (guildConfig.actionlog || guildConfig.modules.Automod !== false)) { + if (guildConfig.modules.Automod !== false || guildConfig.actionlog.messageDelete || guildConfig.actionlog.messageEdit) { + guild.cacheMessages = true; + } + } + + return { + message: message, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + }; + }); +}; diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js new file mode 100644 index 0000000..2ec9361 --- /dev/null +++ b/src/events/messageDelete.js @@ -0,0 +1,18 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function messageDelete(dispatcher, message) { + if (!dispatcher.dyno.isReady || (message.author && message.author.bot)) return Promise.reject(); + if (!message.channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(message.channel.guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(message.channel.guild.id).then(guildConfig => resolve({ + message: message, + guild: message.channel.guild, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/messageDeleteBulk.js b/src/events/messageDeleteBulk.js new file mode 100644 index 0000000..a0bf37d --- /dev/null +++ b/src/events/messageDeleteBulk.js @@ -0,0 +1,19 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function messageDeleteBulk(dispatcher, messages) { + if (!dispatcher.dyno.isReady) return Promise.reject(); + if (!messages[0] || !messages[0].channel || !messages[0].channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(messages[0].channel.guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(messages[0].channel.guild.id).then(guildConfig => resolve({ + messages: messages, + channel: messages[0].channel, + guild: messages[0].channel.guild, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/messageReactionAdd.js b/src/events/messageReactionAdd.js new file mode 100644 index 0000000..403f131 --- /dev/null +++ b/src/events/messageReactionAdd.js @@ -0,0 +1,33 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function messageReactionAdd(dispatcher, message, emoji, userId) { + if (!dispatcher.dyno.isReady || (message.author && message.author.bot)) return Promise.reject(); + if (!message.channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(message.channel.guild, dispatcher.config)) return Promise.reject(); + + const guildConfig = dispatcher.dyno.guilds.get(message.channel.guild.id); + if (guildConfig) { + return Promise.resolve({ + message: message, + emoji: emoji, + userId: userId, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + }); + } + + return dispatcher.dyno.guilds.getOrFetch(message.channel.guild.id).then(guildConfig => { // eslint-disable-line + return { + message: message, + emoji: emoji, + userId: userId, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + }; + }); +}; diff --git a/src/events/messageReactionRemove.js b/src/events/messageReactionRemove.js new file mode 100644 index 0000000..d6f5df5 --- /dev/null +++ b/src/events/messageReactionRemove.js @@ -0,0 +1,33 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function messageReactionRemove(dispatcher, message, emoji, userId) { + if (!dispatcher.dyno.isReady || (message.author && message.author.bot)) return Promise.reject(); + if (!message.channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(message.channel.guild, dispatcher.config)) return Promise.reject(); + + const guildConfig = dispatcher.dyno.guilds.get(message.channel.guild.id); + if (guildConfig) { + return Promise.resolve({ + message: message, + emoji: emoji, + userId: userId, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + }); + } + + return dispatcher.dyno.guilds.getOrFetch(message.channel.guild.id).then(guildConfig => { // eslint-disable-line + return { + message: message, + emoji: emoji, + userId: userId, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + }; + }); +}; diff --git a/src/events/messageReactionRemoveAll.js b/src/events/messageReactionRemoveAll.js new file mode 100644 index 0000000..52caee7 --- /dev/null +++ b/src/events/messageReactionRemoveAll.js @@ -0,0 +1,29 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function messageReactionRemoveAll(dispatcher, message) { + if (!dispatcher.dyno.isReady || (message.author && message.author.bot)) return Promise.reject(); + if (!message.channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(message.channel.guild, dispatcher.config)) return Promise.reject(); + + const guildConfig = dispatcher.dyno.guilds.get(message.channel.guild.id); + if (guildConfig) { + return Promise.resolve({ + message: message, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + }); + } + + return dispatcher.dyno.guilds.getOrFetch(message.channel.guild.id).then(guildConfig => { // eslint-disable-line + return { + message: message, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + }; + }); +}; diff --git a/src/events/messageUpdate.js b/src/events/messageUpdate.js new file mode 100644 index 0000000..2213096 --- /dev/null +++ b/src/events/messageUpdate.js @@ -0,0 +1,20 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function messageUpdate(dispatcher, message, oldMessage) { + if (!dispatcher.dyno.isReady || (message.author && message.author.bot)) return Promise.reject(); + if (!message.channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(message.channel.guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(message.channel.guild.id).then(guildConfig => resolve({ + message: message, + oldMessage: oldMessage, + guild: message.channel.guild, + guildConfig: guildConfig, + isAdmin: dispatcher.dyno.permissions.isAdmin(message.author), + })).catch(() => reject()); + }); +}; diff --git a/src/events/voiceChannelJoin.js b/src/events/voiceChannelJoin.js new file mode 100644 index 0000000..13ef57e --- /dev/null +++ b/src/events/voiceChannelJoin.js @@ -0,0 +1,18 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function voiceChannelJoin(dispatcher, member, channel) { + if (!dispatcher.dyno.isReady || !member || !channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(channel.guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(channel.guild.id).then(guildConfig => resolve({ + member: member, + channel: channel, + guild: channel.guild, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/voiceChannelLeave.js b/src/events/voiceChannelLeave.js new file mode 100644 index 0000000..49b3d52 --- /dev/null +++ b/src/events/voiceChannelLeave.js @@ -0,0 +1,18 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function voiceChannelLeave(dispatcher, member, channel) { + if (!dispatcher.dyno.isReady || !member || !channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(channel.guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(channel.guild.id).then(guildConfig => resolve({ + member: member, + channel: channel, + guild: channel.guild, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/events/voiceChannelSwitch.js b/src/events/voiceChannelSwitch.js new file mode 100644 index 0000000..6324cea --- /dev/null +++ b/src/events/voiceChannelSwitch.js @@ -0,0 +1,19 @@ +'use strict'; + +const { utils } = require('@dyno.gg/dyno-core'); + +module.exports = function voiceChannelSwitch(dispatcher, member, channel, oldChannel) { + if (!dispatcher.dyno.isReady || !member || !channel.guild) return Promise.reject(); + + if (dispatcher.config.handleRegion && !utils.regionEnabled(channel.guild, dispatcher.config)) return Promise.reject(); + + return new Promise((resolve, reject) => { + dispatcher.dyno.guilds.getOrFetch(channel.guild.id).then(guildConfig => resolve({ + member: member, + channel: channel, + oldChannel: oldChannel, + guild: channel.guild, + guildConfig: guildConfig, + })).catch(() => reject()); + }); +}; diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..4e67bd3 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,363 @@ +import * as eris from '@dyno.gg/eris'; + +type ErisChannel = eris.Channel | eris.GuildChannel | eris.PrivateChannel | eris.GroupChannel; + +declare module Dyno { + export interface DynoConfig { + name: string; + author: string; + version: string | number; + lib: string; + poweredBy: string; + prefix: string; + sudopref: string; + adminPrefix: string; + overseers: string[]; + contributors: object[]; + mentions: object[]; + test: boolean; + beta: boolean; + isCore: boolean; + isPremium: boolean; + shared: boolean; + state: number; + stateName: string; + collector: boolean; + invite: string; + logLevel: string; + youtubeKey: string; + cryptkey: string; + defaultPermissions: string; + pkg: any; + dynoGuild: string; + statsGuild: string; + guildLog: string; + largeGuildLog: string; + testGuilds: string[]; + betaGuilds: string[]; + avatar: string; + shardIds: number[]; + clusterIds: number[]; + clusterCount: number; + moduleList: string[]; + shardingStrategy: string; + firstShardOverride: number; + lastShardOverride: number; + shardCountOverride: number; + discordLogger: {[key: string]: string}; + shardWebhook: {[key: string]: string}; + cluster: {[key: string]: string}; + disableHeartbeat: boolean; + logCommands: boolean; + handleRegion: boolean; + regions: string[]; + disableEvents: boolean; + enabledCommandGroups: string[]; + disabledCommandGroups: string[]; + disableHelp: boolean; + maxStreamLimit: number; + maxSongLength: number; + maxPlayingTime: number; + streamLimitThreshold: number; + statsdPrefix: string; + + paths: {[key: string]: string}; + client: ClientConfig; + site: WebConfig; + cleverbot: { key: string }; + redis: { + host: string; + port: number; + auth: string; + } + webhook: { + host: string; + port: number; + } + api: { baseurl: string } + sentry: { + dsn: string; + logLevel: string; + } + emojis: {[key: string]: string}; + carbon: { + key: string; + url: string; + list: string; + info: string; + } + dbots: { + key: string; + url: string; + } + announcements: { + joinMessage: string; + leaveMessage: string; + banMessage: string; + } + automod: { + logChannel: string; + badwords: string[]; + } + permissions: {[key: string]: number}; + permissionsMap:{[key: string]: string}; + } + export interface ClientConfig { + id: string; + secret: string; + token: string; + userid: string; + game: string; + admin: string; + fetchAllUsers: boolean; + disableEveryone: boolean; + maxCachedMessages: number; + messageLogging: boolean; + ws: {[key: string]: string|number}; + } + export interface WebConfig { + host: string; + port: number; + listen_port: number; + secret: string; + statusChannel: string; + statusMessage: string; + } + export interface ClusterConfig { + clusterId: number; + shardCount: number; + clusterCount: number; + firstShardId: number; + lastShardId: number; + rootCtx: any; + client: eris.Client; + restClient: eris.Client; + } + export interface GlobalConfig { + prefix: string; + commands: {[key: string]: boolean} + modules: {[key: string]: boolean} + webhooks: string[]; + dashAccess: string[]; + ignoredUsers: string[]; + nodes: NodeConfig[]; + [key: string]: any; + } + export interface NodeConfig { + host: string; + name?: string; + port?: string; + region?: string; + premium?: boolean; + } + export interface GuildConfig { + _id: string; + prefix: string; + modules: {[key: string]: boolean}; + commands: {[key: string]: boolean}; + subcommands?: {[key: string]: any}; + name?: string; + iconURL?: string; + ownerID?: string; + region?: string; + clientID?: string; + lastActive?: number; + timezone?: string; + mods?: string[]; + modRoles?: string[]; + modonly?: boolean; + deleted?: boolean; + debug?: boolean; + beta?: boolean; + isPremium?: boolean; + premiumInstalled?: boolean; + ignoredUsers?: {[key: string]: any}; + ignoredRoles?: {[key: string]: any}; + ignoredChannels?: {[key: string]: any}; + [key: string]: any; + } + export class Dyno { + public isReady: boolean; + public readonly client: eris.Client; + public readonly restClient: eris.Client; + public readonly config: DynoConfig; + public readonly globalConfig: GlobalConfig; + public readonly logger: any; + public readonly models: any; + public readonly redis: any; + public readonly statsd: any; + public readonly utils: Utils; + [key: string]: any; + } + export class Base { + public readonly client: eris.Client; + public readonly restClient: eris.Client; + public readonly dyno: Dyno; + public readonly cluster: ClusterConfig; + public readonly config: DynoConfig; + public readonly globalConfig: GlobalConfig; + public readonly logger: any; + public readonly ipc: IPCManager; + public readonly webhooks: WebhookManager; + public readonly permissionsManager: PermissionsManager; + public readonly models: any; + public readonly redis: any; + public readonly statsd: any; + public readonly utils: Utils; + constructor(dyno: Dyno, guild?: eris.Guild); + public hasPermissions(guild: eris.Guild, ...perms: string[]): boolean; + public hasChannelPermissions(guild: eris.Guild, channel: ErisChannel, ...perms: string[]): boolean; + public hasRoleHierarchy(guild: eris.Guild, role: eris.Role): boolean; + public regionEnabled(guild: eris.Guild): boolean; + public isAdmin(user: eris.User|eris.Member): boolean; + public isOverseer(user: eris.User|eris.Member): boolean; + public isServerAdmin(member: eris.Member, channel: ErisChannel): boolean; + public isServerMod(member: eris.Member, channel: ErisChannel): boolean; + public getVoiceChannel(member: eris.Member): ErisChannel; + public resolveUser(guild: eris.Guild, user: string, context: any[], exact: boolean): eris.User|eris.Member; + public resolveRole(guild: eris.Guild, role: string): eris.Role; + public resolveChannel(guild: eris.Guild, channel: string): ErisChannel; + public createRole(guild: eris.Guild, options: any): Promise; + public sendDM(userId: string, content: eris.MessageContent): Promise; + public sendMessage(channel: ErisChannel, content: eris.MessageContent, options?: any): Promise; + public executeWebhook(webhook: any, options: any): Promise; + public sendWebhook(channel: ErisChannel, options: any, guildConfig: GuildConfig): Promise; + public sendCode(channel: ErisChannel, content: eris.MessageContent, lang?:string): Promise; + public reply(message: eris.Message, content: eris.MessageContent): Promise; + public success(channel: ErisChannel, content: eris.MessageContent): Promise; + public error(channel: ErisChannel, content: eris.MessageContent, err?: Error): Promise; + public info(message: eris.Message): Promise; + public debug(message: eris.Message): Promise; + public warn(message: eris.Message): Promise; + public logError(err: Error, type: string): void; + } + export class Module extends Base { + constructor(dyno: Dyno); + isEnabled(guild: eris.Guild, module: string|Module, guildConfig?: GuildConfig): boolean; + schedule(interval: string, task: Function): void; + [key: string]: any; + } + export interface ICommand { + group : string; + module? : string; + aliases : string[]; + description : string; + expectedArgs : number; + cooldown : number; + usage : string|string[] + defaultUsage? : string; + disableDM? : boolean; + execute(data: CommandData): Promise<{}>; + } + export interface CommandData { + message: eris.Message; + args?: any[]; + guildConfig?: GuildConfig; + isAdmin?: boolean; + isOverseer?: boolean; + command?: Command; + } + export interface SubCommand { + name: string; + desc: string; + usage: string; + default?: boolean; + cooldown?: number; + } + export class Command extends Base { + public name: string; + public aliases: string[]; + constructor(dyno: Dyno); + public help(message: eris.Message, guildConfig: GuildConfig): Promise; + [key: string]: any; + } + export class Role { + public guild: eris.Guild; + public guildConfig: GuildConfig; + constructor(guild: eris.Guild, guildConfig: GuildConfig); + public static resolve(guild: eris.Guild, role: string): Promise; + public static createRole(guild: eris.Guild, options: eris.RoleOptions): Promise; + public createRole(options: eris.RoleOptions): Promise; + public hasPermissions(guild: eris.Guild, ...perms: string[]): boolean; + public getOrCreate(options: eris.RoleOptions): Promise; + public createOverwritePermissions(channels: ErisChannel[], permissions: string[]): void; + } + export class Channel { + public guild: eris.Guild; + public guildConfig: GuildConfig; + constructor(guild: eris.Guild, guildConfig: GuildConfig); + public static resolve(guild: eris.Guild, channel: string): Promise; + public static create(guild: eris.Guild, options: any): Promise; + public static delete(guild: eris.Guild, channelId: string): Promise; + public create(options: any): Promise; + public delete(channel: ErisChannel): Promise; + } + export class Utils { + public colors: {[key: string]: string}; + public time(fn: Function, label: string): void; + public readdirRecursive(dir: string): string[]; + public existsSync(file: string): boolean; + public sha256(data: any): string; + public encrypt(str: string): string; + public decrypt(str: string): string; + public nextTick(): Promise; + public getRandomArbitrary(min: number, max: number): number; + public getRandomInt(min: number, max: number): number; + public pad(str: string, n: number): string; + public lpad(str: string, n: number): string; + public sumKeys(key: string, data: any): number; + public ucfirst(str: string): string; + public shuffleArray(arr: any[]): any[]; + public regEscape(str: string): string; + public splitMessage(message: string|string[], len: number): any[]; + public clean(str: string): string; + public fullName(user: any, escape: boolean): string; + public regionEnabled(guild: eris.Guild, config: any): boolean; + public sendMessage(channel: ErisChannel, message: string, lang: string, options: any): Promise; + public sortRoles(roles: eris.Role[]): eris.Role[]; + public highestRole(guild: eris.Guild, member: eris.Member): eris.Role; + public hasRoleHierarchy(guild: eris.Guild, clientMember: eris.Member, role: eris.Role): boolean; + public replacer(content: string, data: any, mentionUser: boolean): boolean; + public isArray(value: any): boolean; + public hexToInt(color: string): number; + public getColor(color: string): number; + public parseTimeLimit(limit: string|number): number; + public formatBytes(bytes: number, decimals: number): string; + } + export class IPCManager { + public client: eris.Client; + public dyno: Dyno; + public id: number; + public pid: number; + public commands: Map; + public send(event: string, data: {[key: string]: any}): void; + public onMessage(message: eris.Message): void; + public awaitResponse(op: string, d: {[key: string]: any}): void; + public register(command: Function): void; + } + export class PermissionsManager { + constructor(dyno: Dyno); + public dyno: Dyno; + public isAdmin(user: eris.User|eris.Member): boolean; + public isOverseer(user: eris.User|eris.Member): boolean; + public isServerAdmin(member: eris.Member, channel: ErisChannel): boolean; + public isServerMod(member: eris.Member, channel: ErisChannel): boolean; + public canOverride(channel: ErisChannel, member: eris.Member, command: string): boolean; + } + export class WebhookManager { + public dyno: Dyno; + public config: DynoConfig; + public client: eris.Client; + public avatarUrl: string; + public default: { + username: string; + avatarURL: string; + tts: boolean; + } + constructor(dyno: Dyno); + public getOrCreate(channel: ErisChannel): Promise; + public execute(channel: ErisChannel, options: eris.WebhookPayload, webhook: eris.Webhook): Promise; + } +} + +export = Dyno; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..df70449 --- /dev/null +++ b/src/index.js @@ -0,0 +1,6 @@ +module.exports = { + Dyno: require('./core/Dyno'), + IPCManager: require('./core/managers/IPCManager'), + PermissionsManager: require('./core/managers/PermissionsManager'), + WebhookManager: require('./core/managers/WebhookManager'), +}; diff --git a/src/ipc/cfgset.js b/src/ipc/cfgset.js new file mode 100644 index 0000000..5e09c12 --- /dev/null +++ b/src/ipc/cfgset.js @@ -0,0 +1,25 @@ +'use strict'; + +const dot = require('dot-object'); +const config = require('../core/config'); // eslint-disable-line + +module.exports = function cfgset(dyno, config, message) { + const key = message.d.key; + let value = message.d.value; + + if (!key) { + return process.send({ op: 'resp', d: 'No key specified.' }); + } + + if (!value) { + return process.send({ op: 'resp', d: 'No value specified.' }); + } + + if (value === 'true' || value === 'false') { + value = (value === 'true'); + } + + dot.set(key, value, config); + + return process.send({ op: 'resp', d: 'Success' }); +}; diff --git a/src/ipc/disconnectShard.js b/src/ipc/disconnectShard.js new file mode 100644 index 0000000..29a36bb --- /dev/null +++ b/src/ipc/disconnectShard.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = function disconnectShard(dyno, config, message) { + const client = dyno.client, + id = parseInt(message.d), + shard = client.shards.get(id); + + if (!shard) return; + + shard.disconnect({ reconnect: false }); +}; diff --git a/src/ipc/discrim.js b/src/ipc/discrim.js new file mode 100644 index 0000000..d73ab2f --- /dev/null +++ b/src/ipc/discrim.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = function discrim(dyno, config, message) { + const users = dyno.client.users.filter(u => !u.bot && u.discriminator === `${message.d}`); + if (!users) { + return process.send({ op: 'resp', d: 0 }); + } + + return process.send({ + op: 'resp', + d: users.map(u => { + let user = { + username: u.username, + discriminator: u.discriminator, + avatar: u.avatar, + }; + return user; + }), + }); +}; diff --git a/src/ipc/ping.js b/src/ipc/ping.js new file mode 100644 index 0000000..491c71a --- /dev/null +++ b/src/ipc/ping.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function ping() { + process.send({ op: 'resp', d: 'pong' }); +}; diff --git a/src/ipc/shards.js b/src/ipc/shards.js new file mode 100644 index 0000000..e1a77fc --- /dev/null +++ b/src/ipc/shards.js @@ -0,0 +1,25 @@ +'use strict'; + +const moment = require('moment'); +require('moment-duration-format'); + +module.exports = function shards(dyno) { + try { + const uptime = moment.duration(process.uptime(), 'seconds'); + const started = moment().subtract(process.uptime(), 'seconds').format('llll'); + const client = dyno.client; + const payload = { + shardCount: client.shards.size, + connectedCount: client.shards.filter(s => s.status === 'ready').length, + guildCount: client.guilds.size, + unavailableCount: client.unavailableGuilds.size, + voiceConnections: client.voiceConnections.size, + shards: [...client.shards.keys()], + uptime: uptime.format('w [w] d [d], h [h], m [m], s [s]'), + started: started, + } + process.send({ op: 'resp', d: payload }); + } catch (err) { + dyno.logger.error(err); + } +}; \ No newline at end of file diff --git a/src/ipc/shared.js b/src/ipc/shared.js new file mode 100644 index 0000000..b38174d --- /dev/null +++ b/src/ipc/shared.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = function shared(dyno, config, message) { + const client = dyno.client; + + const user = message.d.user; + if (!user) return process.send({ op: 'error', d: 'Must specify user' }); + + const guilds = [...client.guilds.values()].filter(g => g.members.has(user)); + + if (!guilds || !guilds.length) return process.send({ op: 'resp', d: 0 }); + + process.send({ op: 'resp', d: guilds.length }); +}; diff --git a/src/ipc/stats.js b/src/ipc/stats.js new file mode 100644 index 0000000..bc6867f --- /dev/null +++ b/src/ipc/stats.js @@ -0,0 +1,22 @@ +'use strict'; + +const pidusage = require('pidusage'); + +module.exports = function stats(dyno) { + const client = dyno.client; + const data = { + guilds: client.guilds.size, + users: client.users.size, + retryAfters: Object.keys(client.retryAfters).length, + messages: 0, // client.Messages.length, + voice: client.voiceConnections.size, + mem: process.memoryUsage(), + uptime: process.uptime(), + }; + + pidusage.stat(process.pid, (err, stat) => { + if (err) data.cpu = 'Err'; + data.cpu = stat.cpu.toFixed(2); + process.send({ op: 'resp', d: data }); + }); +}; diff --git a/src/ipc/unload.js b/src/ipc/unload.js new file mode 100644 index 0000000..f59200c --- /dev/null +++ b/src/ipc/unload.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = function unload(dyno, config, message) { + try { + const module = dyno.modules.find(m => m.name.toLowerCase() === message.d.name.toLowerCase()); + + if (!module) { + return process.send({ op: 'resp', d: `Invalid module.` }); + } + + module._unload(); + process.send({ op: 'resp', d: 'success' }); + } catch (err) { + process.send({ op: 'resp', d: err }); + } +}; diff --git a/src/logo.txt b/src/logo.txt new file mode 100644 index 0000000..d3d2cc5 --- /dev/null +++ b/src/logo.txt @@ -0,0 +1,6 @@ + ____ __ __ + / __ \__ ______ ____ _ __/ // / + / / / / / / / __ \/ __ \ | | / / // /_ + / /_/ / /_/ / / / / /_/ / | |/ /__ __/ +/_____/\__, /_/ /_/\____/ |___/ /_/ + /____/ \ No newline at end of file diff --git a/src/modules/AdminHandler.js b/src/modules/AdminHandler.js new file mode 100644 index 0000000..bf12c26 --- /dev/null +++ b/src/modules/AdminHandler.js @@ -0,0 +1,83 @@ +'use strict'; + +const { Module } = require('@dyno.gg/dyno-core'); + +/** + * Carbon Module + * @class Carbon + * @extends Module + */ +class AdminHandler extends Module { + constructor(...args) { + super(...args); + + this.module = 'AdminHandler'; + this.enabled = true; + this.core = true; + this.list = false; + } + + static get name() { + return 'AdminHandler'; + } + + start() { + this.boundListener = this.preMessage.bind(this); + this.client.on('messageCreate', this.boundListener); + } + + unload() { + this.client.removeListener('messageCreate', this.boundListener); + } + + preMessage(message) { + if (!message.channel.guild || !message.author || message.author.bot) return; + + const isAdmin = this.isAdmin(message.author); + const isOverseer = this.isOverseer(message.author); + if (!isAdmin && !isOverseer) return; + + this.dyno.guilds.getOrFetch(message.channel.guild.id) + .then(guildConfig => this.onMessage({ message, guildConfig, isOverseer })); + } + + /** + * Fired when the client receives a message + * @param {Message} message Message object + * @returns {*} + */ + onMessage({ message, guildConfig, isOverseer }) { + if (!guildConfig) return; + + const params = message.content.split(' '); + + // ignore if it's not a prefixed command + if (!params.join(' ').startsWith(this.config.adminPrefix)) return; + + const cmd = params[0].replace(this.config.adminPrefix, '').toLowerCase(); + + if (!cmd.length) return false; + + const commands = this.dyno.commands; + + // command doesn't exist + if (!commands.has(cmd)) return; + + const args = message.content.replace(/ {2,}/g, ' ').split(' ').slice(1); + + // get the command + const command = commands.get(cmd); + + if (isOverseer && command.permissions && !command.overseerEnabled) return; + + // execute command + command._execute({ + message: message, + args: args, + command: cmd, + guildConfig: guildConfig, + }).catch(() => false); + } +} + +module.exports = AdminHandler; diff --git a/src/modules/Carbon.js b/src/modules/Carbon.js new file mode 100644 index 0000000..5c686d5 --- /dev/null +++ b/src/modules/Carbon.js @@ -0,0 +1,89 @@ +'use strict'; + +const axios = require('axios'); +const { Module } = require('@dyno.gg/dyno-core'); +/** + * Carbon Module + * @class Carbon + * @extends Module + */ +class Carbon extends Module { + constructor(...args) { + super(...args); + + this.module = 'Carbon'; + this.enabled = true; + this.core = true; + this.list = false; + this.guildsGauge = this.prom.register.getSingleMetric('dyno_app_guilds_carbon'); + } + + static get name() { + return 'Carbon'; + } + + start() { + this.schedule('*/1 * * * *', this.updateCarbon.bind(this)); + } + + async updateCarbon() { + if (!this.dyno.isReady) return; + if (this.config.state !== 3 || this.dyno.clientOptions.clusterId !== 0) return; + + this.info('Updating carbon stats.'); + + try { + var guildCounts = await this.redis.hgetall(`dyno:guilds:${this.config.client.id}`); + } catch (err) { + return this.logger.error(err); + } + + let guildCount = Object.values(guildCounts).reduce((a, b) => a += parseInt(b), 0); + + this.guildsGauge.set(guildCount); + + const data = { + shard_id: 0, + shard_count: 1, + server_count: guildCount, + }; + + // Post to carbonitex + axios.post(this.config.carbon.url, { + headers: { Accept: 'application/json' }, + key: this.config.carbon.key, + ...Object.assign(data, { + logoid: `https://www.dynobot.net/images/dyno-v2-300.jpg`, + }), + }).catch(() => null); + + // Post to bots.discord.pw + axios.post(this.config.dbots.url, { + headers: { + Authorization: this.config.dbots.key, + Accept: 'application/json', + }, + ...data, + }).catch(() => null); + + // Post to discordbots.org + axios.post(this.config.dbl.url, { + headers: { + Authorization: this.config.dbl.key, + Accept: 'application/json', + }, + ...data, + }).catch(() => null); + + // Post to discordbots.org + axios.post(this.config.botspace.url, { + headers: { + Authorization: this.config.botspace.key, + Accept: 'application/json', + }, + ...data, + }).catch(() => null); + } +} + +module.exports = Carbon; diff --git a/src/modules/CommandHandler.js b/src/modules/CommandHandler.js new file mode 100644 index 0000000..00322ef --- /dev/null +++ b/src/modules/CommandHandler.js @@ -0,0 +1,403 @@ +'use strict'; + +const { Module } = require('@dyno.gg/dyno-core'); +const each = require('async-each'); + +/** + * Carbon Module + * @class Carbon + * @extends Module + */ +class CommandHandler extends Module { + constructor(...args) { + super(...args); + + this.module = 'CommandHandler'; + this.enabled = true; + this.core = true; + this.list = false; + } + + static get name() { + return 'CommandHandler'; + } + + start() { + this.cooldowns = new Map(); + this.dmCooldowns = new Map(); + + this.cooldown = 900; + this.dmCooldown = 10000; + + this.commandLog = []; + + this.schedule('*/1 * * * *', this.clearCooldowns.bind(this)); + + this.footer = [ + `**Additional links and help**\n`, + `[All Commands](${this.config.site.host}/commands)`, + `[Dyno Discord](${this.config.site.host}/discord)`, + `[Add To Your Server](${this.config.site.host}/invite)`, + `[Donate](${this.config.site.host}/donate)`, + ]; + } + + crashReport() { + return this.commandLog.join('\n'); + } + + clearCooldowns() { + each([...this.cooldowns.keys()], id => { + let time = this.cooldowns.get(id); + if ((Date.now() - time) < this.cooldown) return; + this.cooldowns.delete(id); + }); + each([...this.dmCooldowns.keys()], id => { + let time = this.dmCooldowns.get(id); + if ((Date.now() - time) < this.dmCooldown) return; + this.dmCooldowns.delete(id); + }); + } + + logCommand(logEntry) { + this.commandLog.unshift(logEntry); + this.commandLog = this.commandLog.slice(0,20); + } + + handleDM({ message }) { + if (this.config.test || this.config.beta) return; + + const cooldown = this.dmCooldowns.get(message.author.id); + if (cooldown && (Date.now() - cooldown) < this.dmCooldown) return; + this.dmCooldowns.set(message.author.id, Date.now()); + + let msgArray = []; + + msgArray.push('**Commands are disabled in DM.**\n'); + msgArray.push('Use commands **in a server**, type **`?help`** for a list of commands.\n'); + msgArray = msgArray.concat(this.footer); + + return this.client.getDMChannel(message.author.id).then(channel => { + if (!channel) { + this.logger.error('Channel is undefined or null - ' + this.client.privateChannelMap[message.author.id]); + } + this.sendMessage(channel, { embed: { description: msgArray.join('\n') } }); + }); + } + + canExecute(command, e) { + const { message, guildConfig, isAdmin, isOverseer } = e; + const isServerAdmin = this.isServerAdmin(message.member, message.channel); + const isServerMod = this.isServerMod(message.member, message.channel); + + let hasPermission = true; + let isMod = isServerMod || isServerAdmin || isOverseer; + + if (isAdmin) return true; + + const globalConfig = this.dyno.globalConfig || {}; + if (globalConfig.ignoredUsers && globalConfig.ignoredUsers.includes(message.author.id)) { + return false; + } + + if (!isMod && guildConfig.ignoredChannels && guildConfig.ignoredChannels.includes(message.channel.id)) { + return false; + } + if (!isMod && guildConfig.ignoredUsers && guildConfig.ignoredUsers.find(u => u.id === message.author.id)) { + return false; + } + if (!isMod && guildConfig.ignoredRoles && message.member && message.member.roles && + guildConfig.ignoredRoles.find(r => message.member.roles.includes(r))) { + return false; + } + + // check if commands are mod only in the guildConfig, ignore music + if (command.group !== 'Music' && (guildConfig.modonly && !isServerMod)) hasPermission = false; + // check serverAdmin permissions + if (command.permissions === 'serverAdmin' && !isServerAdmin) hasPermission = false; + // check serverMod permissions + if (command.permissions === 'serverMod' && !isServerMod) hasPermission = false; + // ignore admin commands for users without rights + if (command.permissions === 'admin' && !isAdmin) hasPermission = false; + + if (!isServerAdmin) { + const shouldOverride = this.commandEnabled(e, command); + if (typeof shouldOverride === 'boolean') { + hasPermission = shouldOverride; + } + } + + if (command.overseerEnabled && isOverseer) { + if (hasPermission !== true) { + this.logOverride(message, command); + } + return true; + } + + if (command.permissionsFn && command.permissionsFn({ message })) { + return true; + } + + return hasPermission; + } + + commandEnabled(e, command) { + const { message, guildConfig } = e; + + if (command.permissions === 'admin') { + return; + } + + const commandOpts = guildConfig.commands[command.name]; + if (!commandOpts) { + return; + } + + if (commandOpts.ignoredChannels) { + if (commandOpts.ignoredChannels.includes(message.channel.id)) { + return false; + } + + if (message.channel.parentID && commandOpts.ignoredChannels.includes(message.channel.parentID)) { + return false; + } + } + + if (commandOpts.ignoredRoles && commandOpts.ignoredRoles.find(r => message.member.roles.includes(r))) { + return false; + } + + let hasPermissions; + + if (commandOpts.allowedRoles && commandOpts.allowedRoles.find(r => message.member.roles.includes(r))) { + hasPermissions = true; + } + + if (commandOpts.allowedChannels) { + if (commandOpts.allowedChannels.includes(message.channel.id) || + (message.channel.parentID && commandOpts.allowedChannels.includes(message.channel.parentID))) { + hasPermissions = true; + } + } + + if (commandOpts.allowedRoles && commandOpts.allowedRoles.length && !commandOpts.allowedRoles.find(r => message.member.roles.includes(r))) { + hasPermissions = false; + } + + if (commandOpts.allowedChannels && commandOpts.allowedChannels.length) { + if (!commandOpts.allowedChannels.includes(message.channel.id) && !commandOpts.allowedChannels.includes(message.channel.parentID)) { + hasPermissions = false; + } + } + + return hasPermissions; + } + + shouldCooldown(message) { + const cooldown = this.cooldowns.get(message.author.id); + if (cooldown && (Date.now() - cooldown) <= this.cooldown) return true; + this.cooldowns.set(message.author.id, Date.now()); + return false; + } + + /** + * Fired when the client receives a message + * @param {Message} message Message object + * @returns {*} + */ + messageCreate(e) { + const { message, guildConfig, isAdmin } = e; + if (!message.author || message.author.bot) return; + + // handle DM's + if (!message.channel.guild) return this.handleDM(e); + if (!guildConfig) return; + + if (!this.config.isPremium && guildConfig.isPremium && guildConfig.premiumInstalled) { + var livePrefix = guildConfig.livePrefix || null; + } + + if (!this.config.test && message.guild.id !== this.config.dynoGuild) { + // premium checks + if (!this.config.isPremium && guildConfig.isPremium && guildConfig.premiumInstalled) { + return false; + } + if (this.config.isPremium && (!guildConfig.isPremium || !guildConfig.premiumInstalled)) { + return false; + } + } + + // if (!(this.config.isPremium || this.config.test) && guildConfig.clientID && this.config.client.id !== guildConfig.clientID) { + // return false; + // } + + if (this.config.handleRegion && !this.utils.regionEnabled(message.guild, this.config)) { + return false; + } + + const globalConfig = this.dyno.globalConfig, + helpCmds = ['help', 'commands'], + prefix = livePrefix || guildConfig.prefix || this.config.prefix, + prefixes = [ + `<@${this.client.user.id}> `, + `<@!${this.client.user.id}> `, + prefix, + ]; + + if (this.config.localPrefix) { + prefixes.push(this.config.localPrefix); + } + + let msgContent = message.content; + const hasPrefix = prefixes.filter(p => message.content.startsWith(p)); + + // ignore if it's not a prefixed command + if (!(isAdmin && message.content.startsWith(this.config.sudopref)) && (!hasPrefix || !hasPrefix.length)) { + return; + } + + let cmd = message.content.replace(this.config.sudopref, ''); + + for (let pref of prefixes) { + cmd = cmd.replace(pref, ''); + msgContent = `${msgContent.replace(new RegExp(`^${this.utils.regEscape(pref)}`), '')}`; + } + + cmd = cmd.split(' ')[0].toLowerCase(); + if (!cmd.length) return; + + if (this.shouldCooldown(message)) return; + + const commands = this.dyno.commands; + + // command doesn't exist + if (helpCmds.indexOf(cmd) === -1 && !commands.has(cmd)) return; + + const args = msgContent.replace(/ {2,}/g, ' ').split(' ').slice(1); + + // generate and display help + if (helpCmds.indexOf(cmd) > -1) { + if (this.config.disableHelp) return; + + if (args.length && commands.has(args[0])) { + const c = commands.get(args[0]); + return c.help(message, guildConfig); + } + return this.generateHelp({ message, guildConfig, isAdmin }); + } + + // return help for default prefixes + if (message.content.startsWith('?help')) { + return this.generateHelp({ message, guildConfig, isAdmin }); + } + + // get the command + const command = commands.get(cmd); + const module = command.module || command.group; + + if (this.dyno.modules.has(module) && + (guildConfig.modules.hasOwnProperty(module) && guildConfig.modules[module] === false)) return; + + if (globalConfig && globalConfig.commands.hasOwnProperty(cmd) && globalConfig.commands[cmd] === false) { + return; + } + + if (globalConfig && globalConfig.modules.hasOwnProperty(module) && globalConfig.modules[module] === false) { + return; + } + + if (guildConfig.commands.hasOwnProperty(command.name)) { + const commandOpts = guildConfig.commands[command.name]; + if (commandOpts === false || commandOpts.enabled === false) { + return; + } + } + + const isOverseer = this.isOverseer(message.member); + e.isOverseer = isOverseer; + + // check if user has permissions + if (!this.canExecute(command, e)) return; + + const executeStart = Date.now(); + + const logEntry = `[C${this.dyno.clientOptions.clusterId}] [G${message.channel.guild.id}] Command: ${JSON.stringify(message)}`; + this.logCommand(logEntry); + + // execute command + try { + command._execute({ + message: message, + args: args, + command: cmd, + guildConfig: guildConfig, + isAdmin: isAdmin, + isOverseer: isOverseer, + }) + .then(() => { + const time = Date.now() - executeStart; + const isServerAdmin = this.isServerAdmin(message.member, message.channel); + const isServerMod = this.isServerMod(message.member, message.channel); + commands.emit('command', { command, message, guildConfig, args, time, isServerAdmin, isServerMod }); + }) + .catch((err) => { + const time = Date.now() - executeStart; + commands.emit('error', { command, message, guildConfig, args, time }); + }); + } catch (err) { + this.logger.error(err, { + type: 'CommandHandler.command._execute', + command: command.name, + guild: message.channel.guild.id, + shard: message.channel.guild.shard.id, + }); + } + } + + /** + * Generate help + * @param {Message} message Message object + */ + generateHelp({ message, guildConfig, isAdmin }) { + if (this.config.disableHelp) return; + + let prefix = (guildConfig) ? guildConfig.prefix || this.config.prefix : this.config.prefix; + + return this.client.getDMChannel(message.author.id).then(channel => { + if (!channel) { + return this.logger.error('Channel is undefined or null - ' + this.client.privateChannelMap[message.author.id]); + } + + return this.sendMessage(channel, { + content: `The prefix for ${message.guild.name} is \`${prefix}\`\nYou can find a list of commands at `, + embed: { description: this.footer.join('\n') }, + }) + .then(() => { + this.prom.register.getSingleMetric('dyno_app_messages_sent').inc({ type: 'dm' }); + this.prom.register.getSingleMetric('dyno_app_help_sent').inc(); + }) + .catch(() => this.prom.register.getSingleMetric('dyno_app_help_failed').inc()); + }).catch(err => { + this.prom.register.getSingleMetric('dyno_app_help_failed').inc(); + this.logger.error(err); + }); + } + + logOverride(message, command) { + let doc = { + guild: message.channel.guild.id, + user: { + id: message.author.id, + name: message.author.username, + discrim: message.author.discriminator, + }, + command: command.name, + message: message.cleanContent, + }; + + let log = new this.models.OverrideLog(doc); + log.save(err => err ? this.logger.error(err) : false); + } +} + +module.exports = CommandHandler; diff --git a/src/modules/CoordsChannel.js b/src/modules/CoordsChannel.js new file mode 100644 index 0000000..fc900f4 --- /dev/null +++ b/src/modules/CoordsChannel.js @@ -0,0 +1,91 @@ +'use strict'; + +const { Module } = require('@dyno.gg/dyno-core'); + +/** + * CoordsChannel Module + * @class CoordsChannel + * @extends Module + */ +class CoordsChannel extends Module { + constructor(...args) { + super(...args); + + this.module = 'CoordsChannel'; + this.friendlyName = 'Coords Channel Mod'; + this.description = 'Auto delete non-coords messages in coords channels.'; + this.enabled = false; + this.hasPartial = true; + + this.permissions = ['manageMessages']; + } + + static get name() { + return 'CoordsChannel'; + } + + get settings() { + return { + channels: { type: Array, default: [] }, + logChannel: { type: String }, + }; + } + + start() { + this._floatRegex = new RegExp('[+-]?([0-9]*[.])[0-9]+', 'g'); + } + + /** + * Log deleted message or ban + * @param {Message} message Message object + * @param {String} msgContent Message content + * @param {String} reason Reason for deleting/banning + * @returns {void} + */ + log(message, msgContent, reason, guildConfig) { + if (!guildConfig || !guildConfig.coordschannel.logChannel) return; + + const logChannel = this.client.getChannel(guildConfig.coordschannel.logChannel); + + if (!logChannel) return; + + let text = `Deleted message from ${this.utils.fullName(message.author)} in ${message.channel.mention} for ${reason}`; + if (msgContent.length) text += '\n```' + msgContent + '```'; + + this.sendMessage(logChannel, text); + } + + /** + * Handle new message + * @param {Message} message Message object + * @returns {void} + */ + messageCreate({ message, guildConfig }) { + if (!message.author || message.author.bot || !message.member) return; + if (message.isPrivate) return; + + // const guildConfig = await this.dyno.guilds.getOrFetch(message.channel.guild.id); + if (!guildConfig) return; + + if (!this.isEnabled(message.channel.guild, this, guildConfig)) return; + if (!this.hasPermissions(message.channel.guild, 'manageMessages')) return; + + const coordsConfig = guildConfig.coordschannel; + + if (!guildConfig || !coordsConfig || !coordsConfig.channels || !coordsConfig.channels.length) { + return; + } + + if (!coordsConfig.channels.find(c => c.id === message.channel.id)) return; + + const floatMatch = message.content.match(this._floatRegex); + + if (!floatMatch || !floatMatch.length) { + message.delete() + .then(() => this.log(message, message.content, 'talking in coords channel', guildConfig)) + .catch(err => err); + } + } +} + +module.exports = CoordsChannel; diff --git a/src/modules/Dyno.js b/src/modules/Dyno.js new file mode 100644 index 0000000..7f87e80 --- /dev/null +++ b/src/modules/Dyno.js @@ -0,0 +1,607 @@ +'use strict'; + +const { Module } = require('@dyno.gg/dyno-core'); +const { exec } = require('child_process'); +const os = require('os'); +const each = require('async-each'); +const eris = require('@dyno.gg/eris'); +const moment = require('moment'); +const blocked = require('blocked'); +const pidusage = require('pidusage'); + +require('moment-duration-format'); + +/** + * Dyno module + * @class Dyno + * @extends Module + */ +class Dyno extends Module { + constructor(...args) { + super(...args); + + this.module = 'Dyno'; + this.description = 'Dyno stats, guild, and dm logs.'; + this.enabled = false; + this.admin = true; + this.hasPartial = false; + } + + static get name() { + return 'Dyno'; + } + + get settings() { + return { + statsEnabled: Boolean, + logEnabled: Boolean, + dmEnabled: Boolean, + carbonEnabled: Boolean, + automodEnabled:Boolean, + channel: String, + message: String, + logChannel: String, + dmChannel: String, + lgChannel: String, + carbonMessage: String, + carbonChannel: String, + automodChannel:String, + }; + } + + async start() { + this.statMessage = null; + this.carbonMessages = new Map(); + this.listeners = new Map(); + this.jobs = []; + + // create the cron job + this.schedule('0,15,30,45 * * * * *', this.updateStats.bind(this)); + + // remove cached docs + this.schedule('0 0 * * * *', () => { + if (this._docs) delete this._docs; + }); + + this.exceptionHandler = this.handleException.bind(this); + + let exceptionHandlers = process.listeners('uncaughtException'); + if (exceptionHandlers && exceptionHandlers.length) { + process.removeListener('uncaughtException', exceptionHandlers[0]); + } + + process.on('uncaughtException', this.exceptionHandler); + + this.commandSuccessListener = this.onCommandSuccess.bind(this); + this.commandFailureListener = this.onCommandFailure.bind(this); + + this.dyno.commands.on('command', this.commandSuccessListener); + this.dyno.commands.on('error', this.commandFailureListener); + + this._blocked = blocked(ms => { + this.prom.register.getSingleMetric('dyno_app_node_blocked').inc(); + }, { threshold: 100 }); + + this.guildCreateListener = this.onGuildCreate.bind(this); + this.guildDeleteListener = this.onGuildDelete.bind(this); + this.messageCreateListener = this.onMessage.bind(this); + this.rawWSListener = this.onRawEvent.bind(this); + + this.client.on('guildCreate', this.guildCreateListener); + this.client.on('guildDelete', this.guildDeleteListener); + this.client.on('messageCreate', this.messageCreateListener); + this.client.on('rawWS', this.rawWSListener); + } + + unload() { + for (const [event, listener] of this.listeners.entries()) { + this.ipc.removeListener(event, listener); + } + + this.dyno.commands.removeListener('command', this.commandSuccessListener); + this.dyno.commands.removeListener('command', this.commandFailureListener); + this.client.removeListener('guildCreate', this.guildCreateListener); + this.client.removeListener('guildDelete', this.guildDeleteListener); + this.client.removeListener('messageCreate', this.messageCreateListener); + this.client.removeListener('rawWS', this.rawWSListener); + + process.removeListener('uncaughtException', this.exceptionHandler); + + clearInterval(this._blocked); + } + + /** + * Uncaught exception handler + * @param {Object} err Error object + */ + handleException(err) { + if (!err || (typeof err === 'string' && !err.length)) { + return this.logger.error('An undefined exception occurred.'); + } + + let ignored = ['ECONNRESET', 'ENETUNREACH', 'ETIMEDOUT']; + + for (let key of ignored) { + if (err && err.message && err.message.includes(key)) { + return this.logger.error(err); + } + + if (err && err.code && err.code === key) { + return this.logger.error(err); + } + } + + try { + this.logger.error(err); + } catch (e) { + console.error(err); // eslint-disable-line + } finally { + setTimeout(() => process.exit(1), 3000); + } + } + + /** + * Parse guild object to broadcast through IPC + * @param {Guild} guild Guild object + * @returns {{id: String, name: (String|null), memberCount: null, icon: null, iconURL: null, ownerID: null, shard: null}} + */ + parseGuild(guild) { + return { + id: guild.id, + name: guild.name || null, + memberCount: guild.memberCount || null, + icon: guild.icon || null, + iconURL: guild.iconURL || null, + ownerID: guild.ownerID || null, + shard: guild.shard ? guild.shard.id || null : null, + }; + } + + /** + * Handle guild create event + * @param {Guild} guild Guild object + */ + onGuildCreate(guild) { + this.logGuildEvent('Created', this.parseGuild(guild)); + this.prom.register.getSingleMetric('dyno_app_guild_events').inc({ type: 'create' }); + } + + /** + * Handle guild delete event + * @param {Guild} guild Guild object + */ + onGuildDelete(guild) { + this.logGuildEvent('Deleted', this.parseGuild(guild)); + this.prom.register.getSingleMetric('dyno_app_guild_events').inc({ type: 'delete' }); + } + + /** + * Handle direct messages received + * @param {Message} message Message object + */ + // messageCreate({ message }) { + // if (message.channel.guild || message.author.id === this.client.user.id) return; + + // this.ipc.send('broadcast', { + // op: 'directMessage', + // d: { + // id: message.id, + // author: { + // id: message.author.id, + // username: message.author.username, + // discriminator: message.author.discriminator, + // avatar: message.author.icon, + // avatarURL: message.author.avatarURL, + // }, + // content: message.content, + // }, + // }); + // } + + onMessage() { + this.prom.register.getSingleMetric('dyno_app_message_events').inc({ type: 'create' }); + } + + onRawEvent() { + this._events = this._events || 0; + this._events++; + } + + onCommandSuccess({ command, time }) { + this.prom.register.getSingleMetric('dyno_app_command_success').inc({ group: (command.group || command.module), name: command.name }); + this.prom.register.getSingleMetric('dyno_app_command_time').observe({ group: (command.group || command.module), name: command.name }, time); + } + + onCommandFailure({ command, time }) { + this.prom.register.getSingleMetric('dyno_app_command_error').inc({ group: (command.group || command.module), name: command.name }); + this.prom.register.getSingleMetric('dyno_app_command_time').observe({ group: (command.group || command.module), name: command.name }, time); + } + + /** + * Set a message id in the database + * @param {String} key Setting key name + * @param {String} id Guild id + * @param {Message} msg Message object + * @returns {Promise} + */ + async setMessage(key, id, msg) { + key = `dyno.${key}`; + try { + await this.dyno.guilds.update(id, { $set: { [key]: msg ? msg.id : null } }); + return Promise.resolve(); + } catch (e) { + this.logger.error(e); + return Promise.reject(e); + } + } + + /** + * Log an event to the dev/guild log + * @param {String} event Event to log + */ + async logEvent(event) { + try { + const docs = await this.getDocs(); + if (!docs || !docs.length) return; + + each(docs, async doc => { + const guild = this.client.guilds.get(doc._id); + + if (!guild || !await this.isEnabled(guild, this)) return; + if (!doc.dyno || !doc.dyno.logChannel || !doc.dyno.logEnabled) return; + + const channel = this.client.getChannel(doc.dyno.logChannel); + + this.sendMessage(channel, `[${moment().format('hh:mm:ss a')}] ${event}`); + }); + } catch (err) { + this.logger.error(err); + } + } + + /** + * Log a guild create/delete event + * @param {String} event Event to log + * @param {Guild} guild The guild for the logged event + * @returns {void} + */ + async logGuildEvent(event, guild) { + if (!this.config.isCore) return; + if (!guild || !guild.id || !guild.name) return; + + if (guild.memberCount && guild.memberCount >= 250) { + const color = event === 'Created' ? 2347360 : 16729871; + const memberText = guild.memberCount ? ` | ${guild.memberCount} members` : ''; + const shortName = guild.name.length >= 31 ? `${guild.name.substr(0, 28)}...` : guild.name; + const message = { + username: shortName, + avatarURL: guild.iconURL, + embeds: [{ + color: color, + title: `Guild ${event}: ${guild.name}${memberText}`, + url: `${this.config.site.host}/server/${guild.id}`, + timestamp: new Date(), + footer: { + text: `${this.config.stateName} | Shard ${guild.shard} | ID: ${guild.id}`, + }, + }], + }; + + if (!this.guildlogChannel) { + this.guildlogChannel = await this.dyno.restClient.getRESTChannel('341532519306362881').catch(() => null); + } + + if (this.guildlogChannel) { + let guildConfig = await this.dyno.guilds.getOrFetch(guild.id); + if (guildConfig) { + this.sendWebhook(this.guildlogChannel, message, guildConfig); + } + } + } + + const log = new this.models.GuildLog({ + id: guild.id, + guild: guild, + action: event, + }); + + try { + log.save(err => err ? this.logger.error(err) : false); + } catch (err) { + this.logger.error(err); + } + } + + /** + * Log direct messages to a channel + * @param {Message} message Message object + * @returns {boolean} + */ + async logDirectMessage(message) { + try { + const docs = await this.getDocs(); + if (!docs) return false; + + each(docs, async doc => { + const guild = this.client.guilds.get(doc._id); + + if (!await this.isEnabled(guild, this)) return; + if (!doc.dyno || !doc.dyno.dmEnabled || !doc.dyno.dmChannel) return; + + const channel = this.client.getChannel(doc.dyno.dmChannel); + + const msgArray = [ + `[${moment().format('hh:mm:ss a')}] From: ${message.author.username}#${message.author.discriminator}`, + '```' + message.content + '```', + ]; + + this.sendMessage(channel, msgArray.join('\n')); + }); + + return true; + } catch (err) { + this.logger.error(err, { type: 'dyno.logDirectMessage' }); + } + } + + getFFmpegs() { + return new Promise((resolve) => { + exec(`pgrep ffmpeg | wc -l | tr -d ' '`, (err, stdout, stderr) => { + if (err || stderr) { + return resolve(0); + } + + return resolve(stdout); + }); + }); + } + + getCpuUsage() { + return new Promise((resolve) => { + pidusage.stat(process.pid, (err, stat) => { + if (err) return resolve(); + return resolve(stat.cpu.toFixed(2)); + }); + }); + } + + getDocs() { + if (this._docs) return Promise.resolve(this._docs); + + return new Promise((resolve, reject) => + this.models.Server.find({ 'modules.Dyno': true }).lean().exec() + .catch(reject) + .then(docs => { + this._docs = docs; + resolve(docs); + })); + } + + createChannel(guild, name, type, reason) { + return this.client.requestHandler.request('POST', `/guilds/${guild.id}/channels`, true, { + name, + type, + reason, + }).then((channel) => new eris.GuildChannel(channel)); + } + + /** + * Update bot/shard stats + */ + async updateStats() { + let voiceConnections = this.client.voiceConnections.size || 0, + playingConnections = [...this.client.voiceConnections.values()].filter(c => c.playing && !c.ended); + + let coredata = { + id: this.dyno.clientOptions.clusterId, + pid: process.pid, + guilds: this.client.guilds.size, + users: this.client.users.size, + mem: process.memoryUsage(), + uptime: process.uptime(), + time: Date.now(), + isPremium: this.config.isPremium || null, + events: this._events, + }; + + const clusterId = this.dyno.clientOptions.clusterId.toString(); + const state = this.config.state; + let status; + + try { + const uptime = moment.duration(process.uptime(), 'seconds'); + const started = moment().subtract(process.uptime(), 'seconds').format('llll'); + + status = { + env: this.config.env, + server: this.config.stateName, + clusterId: clusterId, + shardCount: this.client.shards.size, + connectedCount: this.client.shards.filter(s => s.status === 'ready').length, + guildCount: this.client.guilds.size, + unavailableCount: this.client.unavailableGuilds.size, + voiceConnections: voiceConnections, + shards: [...this.client.shards.keys()], + shardStatus: [...this.client.shards.values()].map(s => ({ id: s.id, status: s.status })), + uptime: uptime.format('w [w] d [d], h [h], m [m], s [s]'), + started: started, + }; + + this.redis.set(`dyno.status.${state}.${clusterId}`, JSON.stringify(status), 'EX', 90); + this.redis.set(`dyno:status:${this.config.env}:${clusterId}`, JSON.stringify(status), 'EX', 90); + } catch (err) { + this.logger.error(err); + } + + if (!this.dyno.isReady) return; + + let data = Object.assign({}, coredata, { + voice: voiceConnections || 0, + playing: playingConnections ? playingConnections.length : 0, + }); + + data.cpu = await this.getCpuUsage(); + + this.prom.register.getSingleMetric('dyno_app_gateway_events').set(this._events); + this._events = 0; + + this.prom.register.getSingleMetric('dyno_app_guild_count').set(data.guilds); + this.prom.register.getSingleMetric('dyno_app_user_count').set(data.users); + + if (this.config.isCore) { + try { + await Promise.all([ + this.redis.hset(`dyno:guilds:${this.config.client.id}`, `${state}:${clusterId}`, this.client.guilds.size), + this.redis.hset(`dyno:cstats:${this.config.client.id}`, `${state}:${clusterId}`, JSON.stringify(coredata)), + ]); + } catch (err) { + this.logger.error(err, { type: 'dyno.updateStats.coreStats' }); + } + } + + try { + await Promise.all([ + this.redis.hset(`dyno:vc`, `${state}:${clusterId}`, data.voice || 0), + this.redis.hset(`dyno:stats:${state}`, clusterId || 0, JSON.stringify(data)), + ]); + } catch (err) { + this.logger.error(err, { type: 'dyno.updateStats.stats' }); + } + + if (this.dyno.clientOptions.clusterId !== 0) { + return; + } + + if (!this.statsGuild || !this.guildChannels) { + let [guild, guildChannels] = await Promise.all([ + this.restClient.getRESTGuild(this.config.statsGuild).catch(() => false), + this.restClient.getRESTGuildChannels(this.config.statsGuild).catch(() => false), + ]); + this.statsGuild = guild; + this.guildChannels = guildChannels; + } + + if (!this.guildChannels || !this.guildChannels.size || !this.guildChannels.length) { + return; + } + + const channelName = `stats-${this.config.stateName.toLowerCase()}`; + this.statsChannel = this.guildChannels.find(c => c.name.toLowerCase() === channelName); + if (!this.statsChannel) { + try { + this.statsChannel = await this.createChannel(this.statsGuild, channelName); + this.guildChannels = await this.restClient.getRESTGuildChannels(this.config.statsGuild).catch(() => false); + // this.statsChannel = await this.client.createChannel(this.statsGuild.id, channelName); + } catch (err) { + return this.logger.error(err, { type: 'dyno.updateStats.createChannel' }); + } + } + + if (!this.statsChannel) { + return; + } + + try { + var [shards, vcs, ffmpegs] = Promise.all([ + this.redis.hgetall(`dyno:stats:${this.config.state}`), + this.redis.hgetall(`dyno:vc`), + this.getFFmpegs(), + ]); + } catch (err) {}; + + return this.sendStats(this.statsGuild, shards, vcs, ffmpegs); + } + + /** + * Send bot/shard stats to discord + * @param {Array.} guilds Array of guild configs + */ + async sendStats(guild, shards, vcs, ffmpegs) { + const data = {}; + + function sum(key, data) { + return data.reduce((a, b) => { + a += parseInt(b[key]); + return a; + }, 0); + } + + data.shards = []; + for (const key in shards) { + const shard = JSON.parse(shards[key]); + data.shards.push(shard); + } + + data.guilds = sum('guilds', data.shards); + data.users = sum('users', data.shards); + data.voice = sum('voice', data.shards); + data.playing = sum('playing', data.shards); + data.allConnections = [...Object.values(vcs)].reduce((a, b) => a + parseInt(b), 0); + + const embed = { + author: { + name: 'Dyno', + icon_url: `${this.config.avatar}`, + }, + fields: [ + { name: 'Guilds', value: `${data.guilds.toString()}`, inline: true }, + { name: 'Users', value: `${data.users.toString()}`, inline: true }, + { name: 'Streams', value: `${data.playing}/${data.voice}`, inline: true }, + { name: 'FFMPEGs', value: `${ffmpegs.toString()}`, inline: true }, + { name: 'Load Avg', value: `${os.loadavg().map(n => n.toFixed(3)).join(', ')}`, inline: true }, + { name: 'Free Mem', value: `${this.utils.formatBytes(os.freemem())} / ${this.utils.formatBytes(os.totalmem())}`, inline: true }, + ], + footer: { + text: `${this.config.stateName} | Cluster ${this.dyno.clientOptions.clusterId.toString()}`, + }, + timestamp: new Date(), + }; + + let description = []; + + for (const shard of data.shards) { + const uptime = moment.duration(shard.uptime, 'seconds') + .format('w[w] d[d], h[h], m[m], s[s]'); + + if (data.shards.length > 25) { + description.push(`\`C${this.utils.pad(shard.id, 2)} | ${this.utils.pad(shard.pid, 5)} | ${shard.guilds}g | ${this.utils.pad(shard.voice, 2)}vc | ${this.utils.pad(shard.cpu + '%', 6)} | ${this.utils.pad(this.utils.formatBytes(shard.mem.rss, 2), 9)} | ${uptime}\``); + } else { + embed.fields.push({ + name: `C${shard.id} | ${shard.pid} | ${shard.guilds} guilds | ${shard.voice} vc`, + value: `${shard.cpu}%, ${this.utils.formatBytes(shard.mem.rss, 2)}, ${uptime}`, + inline: false, + }); + } + } + + if (description.length) { + const fields = this.utils.splitMessage(description, 1000); + for (let field of fields) { + embed.fields.push({ name: '\u200b', value: field }); + } + } + + let message = this.statMessage; + + if (!message) { + await this.client.getMessages(this.statsChannel.id, 10).then(messages => { + if (!messages || !messages.length) return; + + let msg = messages.pop(); + if (!msg) return; + + this.statMessage = message = msg; + }).catch(() => false); + } + + if (!message) { + this.client.createMessage(this.statsChannel.id, { embed }).then(msg => { + this.statMessages = message = msg; + }).catch(err => this.logger.error(err, { type: 'dyno.sendStats.sendMessage' })); + } + + message.edit({ embed }).catch(err => this.logger.error(err, { type: 'dyno.sendStats.editMessage' })); + } +} + +module.exports = Dyno; diff --git a/src/modules/DynoManager.js b/src/modules/DynoManager.js new file mode 100644 index 0000000..b14f32b --- /dev/null +++ b/src/modules/DynoManager.js @@ -0,0 +1,37 @@ +'use strict'; + +const { Module } = require('@dyno.gg/dyno-core'); + +/** + * DynoManager module + * @class DynoManager + * @extends Module + */ +class DynoManager extends Module { + constructor(...args) { + super(...args); + + this.module = 'DynoManager'; + this.description = 'Dyno manager.'; + this.core = true; + this.list = false; + this.enabled = true; + this.hasPartial = false; + } + + static get name() { + return 'DynoManager'; + } + + guildCreate(guild) { + if (!this.config.isCore) return; + const clients = this.globalConfig.clients; + if (!clients || !clients.length) return; + for (let client of clients) { + if (client.userid === this.config.client.id) continue; + if (guild.members.has(client.userid)) { + this.client.leaveGuild(guild.id); + } + } + } +} diff --git a/src/modules/Premium.js b/src/modules/Premium.js new file mode 100644 index 0000000..087e25c --- /dev/null +++ b/src/modules/Premium.js @@ -0,0 +1,76 @@ +'use strict'; + +const eris = require('@dyno.gg/eris'); +const { Module } = require('@dyno.gg/dyno-core'); +const { Permissions } = eris.Constants; + +class Premium extends Module { + constructor(...args) { + super(...args); + + this.module = 'Premium'; + this.description = 'Premium helper module.'; + this.core = true; + this.list = false; + this.enabled = true; + this.hasPartial = false; + } + + static get name() { + return 'Premium'; + } + + async guildRoleCreate({ guild, role, guildConfig }) { + // if (this.config.isPremium || this.config.beta) return; + if (this.config.isPremium || this.config.beta || this.config.test) return; + if (role.name !== 'Dyno Premium' || role.managed !== true) return; + if (!guildConfig.isPremium) return; + + await new Promise(res => setTimeout(res, 2000)); + + const premiumMember = guild.members.get('168274283414421504'); + if (!premiumMember) return; + + const clientMember = guild.members.get(this.dyno.user.id); + if (!clientMember) return; + + const dynoRole = guild.roles.find(r => r.name === 'Dyno' && r.managed === true); + if (!dynoRole || !dynoRole.position) return; + + let textPerms = ['readMessages', 'sendMessages', 'embedLinks', 'externalEmojis'], + voicePerms = ['voiceConnect', 'voiceSpeak', 'voiceUseVAD']; + + let pos = dynoRole.position - 1; + + this.client.editRolePosition(guild.id, role.id, pos).catch(err => this.logger.error(err.message)); + + for (let channel of guild.channels.values()) { + let dynoPerms = channel.permissionsOf(clientMember.id), + premiumPerms = channel.permissionsOf(premiumMember.id); + + if (channel.type === 0) { + if ((dynoPerms.has('readMessages') && !premiumPerms.has('readMessages')) || + (dynoPerms.has('sendMessages') && !premiumPerms.has('sendMessages'))) { + let permInt = textPerms.reduce((a, b) => { + a |= Permissions[b]; + return a; + }, 0); + channel.editPermission(role.id, permInt, 0, 'role').catch(() => false); + } + } else if (channel.type === 2) { + if ((dynoPerms.has('voiceConnect') && !premiumPerms.has('voiceConnect')) || + (dynoPerms.has('voiceSpeak') && !premiumPerms.has('voiceSpeak'))) { + let permInt = voicePerms.reduce((a, b) => { + a |= Permissions[b]; + return a; + }, 0); + channel.editPermission(role.id, permInt, 0, 'role').catch(() => false); + } + } + } + + this.dyno.guilds.update(guild.id, { $set: { premiumInstalled: true } }); + } +} + +module.exports = Premium; diff --git a/src/modules/ShardStatus.js b/src/modules/ShardStatus.js new file mode 100644 index 0000000..26a6980 --- /dev/null +++ b/src/modules/ShardStatus.js @@ -0,0 +1,168 @@ +'use strict'; + +const { Collection, Module } = require('@dyno.gg/dyno-core'); +const axios = require('axios'); +const blocked = require('blocked'); +const moment = require('moment'); + +/** + * ShardStatus module + * @class ShardStatus + * @extends Module + */ +class ShardStatus extends Module { + constructor(...args) { + super(...args); + + this.module = 'ShardStatus'; + this.description = 'Dyno core module.'; + this.core = true; + this.list = false; + this.enabled = true; + this.hasPartial = false; + + this.cmClient = this.dyno.cmClient; + } + + static get name() { + return 'ShardStatus'; + } + + start() { + this.shardListeners = new Collection(); + + this.shardListeners.set('shardReady', this.shardReady.bind(this)); + this.shardListeners.set('shardResume', this.shardResume.bind(this)); + this.shardListeners.set('shardDisconnect', this.shardDisconnect.bind(this)); + + for (let [event, listener] of this.shardListeners) { + this.client.on(event, listener); + } + + this.blockHandler = blocked(ms => { + const id = this.cluster.clusterId.toString(); + const text = `C${id} blocked for ${ms}ms`; + + this.logger.info(`[Dyno] ${text}`); + + try { + this.cmClient.request('blocked', { text }); + } catch (err) { + // pass + } + }, { threshold: 10000 }); + } + + unload() { + if (this.blockHandler) { + clearInterval(this.blockHandler); + this.blockHandler = null; + } + if (!this.shardListeners.size) return; + for (let [event, listener] of this.shardListeners) { + this.client.removeListener(event, listener); + } + } + + /** + * Shard ready handler + * @param {Number} id Shard ID + */ + shardReady(id) { + this.logger.info(`[Dyno] Shard ${id} ready.`); + + try { + this.postStat('ready'); + this.cmClient.request('shardReady', { id, cluster: this.cluster.clusterId }); + } catch (err) { + // pass + } + } + + /** + * Shard resume handler + * @param {Number} id Shard ID + */ + shardResume(id) { + this.logger.info(`[Dyno] Shard ${id} resumed.`); + + try { + this.postStat('resume'); + this.cmClient.request('shardResume', { id, cluster: this.cluster.clusterId }); + } catch (err) { + // pass + } + } + + /** + * Shard disconnect handler + * @param {Error} err Error if one is passed + * @param {Number} id Shard ID + */ + shardDisconnect(err, id) { + if (err) { + const shard = this.client.shards.get(id); + this.logger.warn(err, { type: 'dyno.shardDisconnect', cluster: this.cluster.clusterId, shard: id, trace: shard.discordServerTrace }); + } + + this.logger.info(`[Dyno] Shard ${id} disconnected`); + + let data = { id, cluster: this.cluster.clusterId }; + if (err) { + if (err.code) { + data.err = err.code; + } else if (err.message) { + data.err = err.message; + } + } + + try { + this.postStat('disconnect'); + this.cmClient.request('shardDisconnect', data); + } catch (err) { + // pass + } + } + + async postStat(key) { + const day = moment().format('YYYYMMDD'); + const hr = moment().format('YYYYMMDDHH'); + + this.prom.register.getSingleMetric('dyno_app_discord_shard').inc({ type: key }); + + const [dayExists, hrExists] = await Promise.all([ + this.redis.exists(`shard.${key}.${day}`), + this.redis.exists(`shard.${key}.${hr}`), + ]); + + const multi = this.redis.multi(); + + multi.incrby(`shard.${key}.${day}`, 1); + multi.incrby(`shard.${key}.${hr}`, 1); + + if (!dayExists) { + multi.expire(`shard.${key}.${day}`, 604800); + } + + if (!hrExists) { + multi.expire(`shard.${key}.${hr}`, 259200); + } + + multi.exec().catch(err => this.logger.error(err)); + } + + postWebhook(webhook, payload) { + return new Promise((resolve, reject) => + axios.post(webhook, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + ...payload + }) + .then(resolve) + .catch(reject)); + } +} + +module.exports = ShardStatus; diff --git a/src/modules/Starboard.js b/src/modules/Starboard.js new file mode 100644 index 0000000..3e06855 --- /dev/null +++ b/src/modules/Starboard.js @@ -0,0 +1,87 @@ +'use strict'; + +const { Module } = require('@dyno.gg/dyno-core'); + +/** + * Starboard module + * @class Starboard + * @extends Module + */ +class Starboard extends Module { + constructor(...args) { + super(...args); + + this.module = 'Starboard'; + this.description = 'Create a public starboard for members to star messages.'; + this.enabled = false; + this.hasPartial = false; + this.vipOnly = true; + } + + static get name() { + return 'Starboard'; + } + + get settings() { + return { + channels: { type: Array }, + minStars: { type: Number, default: 2 }, + } + } + + start() { + this.messages = new Map(); + } + + async getOrFetchMessage(id) { + if (this.messages.has(id)) { + return this.messages.get(id); + } + let docs = await this.models.StarredMessages.find({ id: id }).limit(1).lean().exec(); + if (!docs || !docs.length) { + throw 'Message not found'; + } + this.messages.set(id, docs[0]); + return docs[0]; + } + + async star(message, user, remove) { + message = await this.getOrFetchMessage(message.id); + if (!message) { + message = { + id: message.id, + guild: message.guild.id, + channel: message.channel.id, + author: message.author.toJSON(), + content: message.content, + timestamp: message.timestamp, + stars: 0, + }; + + let doc = this.models.StarredMessages(message); + try { + await doc.save(); + } catch (err) { + return this.logger.error(err); + } + } + + let change = remove ? -1 : 1; + message.stars += change; + return this.models.StarredMessages.update({ id: message.id }, { $inc: { stars: change } }).catch(() => null); + } + + messageReactionAdd({ message, guild, emoji, userId, guildConfig }) { + if (!this.isEnabled(guild, this.module, guildConfig)) return; + if (!emoji || !userId) return; + } + + messageReactionRemove({ message, guild, emoji, userId, guildConfig }) { + if (!this.isEnabled(guild, this.module, guildConfig)) return; + if (!emoji || !userId) return; + } + + messageReactionRemoveAll({ message, guild, guildConfig }) { + if (!this.isEnabled(guild, this.module, guildConfig)) return; + } +} diff --git a/src/modules/modules.js b/src/modules/modules.js new file mode 100644 index 0000000..3a2fddb --- /dev/null +++ b/src/modules/modules.js @@ -0,0 +1,35 @@ +'use strict'; + +const config = require('../core/config'); +const logger = require('../core/logger'); + +const importedModules = {}; +const loadedModules = {}; + +try { importedModules.Automod = require('@dyno.gg/automod').Automod; } catch (err) {} +try { importedModules.Autoroles = require('@dyno.gg/autoroles').Autoroles; } catch (err) {} +try { importedModules.CustomCommands = require('@dyno.gg/customcommands').CustomCommands; } catch (err) {} +try { importedModules.Manager = require('@dyno.gg/manager').Manager; } catch (err) {} +try { importedModules.Moderation = require('@dyno.gg/moderation').Moderation; } catch (err) {} +try { importedModules.Music = require('@dyno.gg/music').Music; } catch (err) {} +try { importedModules.Fun = require('@dyno.gg/fun').Fun; } catch (err) {} +try { var modules = require('@dyno.gg/modules'); } catch (err) {} + +for (let [key, module] of Object.entries(importedModules)) { + if (config.modules.includes(key)) { + loadedModules[key] = module; + } +} + +if (modules) { + for (let [key, module] of Object.entries(modules)) { + if (config.modules.includes(key)) { + loadedModules[key] = module; + } + } +} + +module.exports = { + hasModules: true, + modules: loadedModules, +}; diff --git a/src/start.js b/src/start.js new file mode 100644 index 0000000..71f8fa2 --- /dev/null +++ b/src/start.js @@ -0,0 +1,132 @@ +'use strict'; + +const fs = require('fs'); +const repl = require('repl'); +const util = require('util'); +const childProcess = require('child_process'); +const config = require('./core/config'); +require('./core/metrics'); + +global.Promise = require('bluebird'); + +require.extensions['.txt'] = (module, filename) => { + module.exports = fs.readFileSync(filename, 'utf8'); +}; + +const env = process.env.NODE_ENV; +const logo = require('./logo.txt'); + +process.on('SIGINT', () => process.exit()); + +if ((process.env.id || process.env.shardId) && process.env.shardCount) { + process.env.clusterId = parseInt(process.env.id, 10); + + if (process.env.DEBUG_BLOCKS) { + const blocked = require('blocked-at'); + const { stop } = blocked((time, stack) => { + console.log(`Blocked for ${time}ms, operation started here:`, stack); + }, { threshold: 5000 }); + + setTimeout(() => stop(), 900000); + } + + + console.log(`[C${process.env.clusterId}] Process ${process.pid} online.`); + + const Dyno = require('./core/Dyno'); + const options = {}; + + if (process.env.hasOwnProperty('awaitReady')) { + options.awaitReady = process.env.awaitReady; + } + + if (process.env.shardId) { + options.shardId = parseInt(process.env.shardId, 10); + } + + if (process.env.clusterId) { + options.clusterId = parseInt(process.env.clusterId, 10); + } + + if (process.env.shardCount) { + options.shardCount = parseInt(process.env.shardCount, 10); + } + + if (process.env.clusterCount) { + options.clusterCount = parseInt(process.env.clusterCount, 10); + } + + if (process.env.firstShardId) { + options.firstShardId = process.env.firstShardId ? parseInt(process.env.firstShardId, 10) : null; + options.lastShardId = process.env.lastShardId ? parseInt(process.env.lastShardId, 10) : null; + } + + if (env === 'development') { + require('longjohn'); + } + + const dyno = new Dyno(); + dyno.setup(options, require); +} else if (process.env.manager) { + const Manager = require('./core/clusterManager/Manager'); + const clusterManager = new Manager(); // eslint-disable-line +} else { + init().then(() => { + const Manager = require('./core/processManager/Manager'); + const processManager = new Manager(); // eslint-disable-line + }); +} + +function log(...args) { + process.stdout.write(`${util.format.apply(null, args)}\n`); +} + +async function init() { + log(logo, '\n'); + log(`Starting [${env} ${config.pkg.version}]`); + + if (env === 'production') { + return Promise.resolve(); + } + + try { + log(`Packages:`); + await listPackages(); + } catch (err) {} + + try { + log(`Repo:`); + await gitInfo(); + } catch (err) {} + + return Promise.resolve(); +} + +function listPackages() { + return new Promise((res, rej) => + childProcess.exec('yarn list --depth=0 --pattern "@dyno.gg"', (err, stdout) => { + if (err) { + return rej(err); + } + let output = stdout.split('\n'); + log(`${output.slice(1, output.length - 1).join('\n')}\n`); + res(); + })); +} + +function gitInfo() { + return new Promise((res, rej) => + childProcess.exec('git log -n 3 --no-color --pretty=format:\'[ "%h", "%s", "%cr", "%an" ],\'', (err, stdout) => { + if (err) { + return rej(err); + } + + let str = stdout.split('\n').join(''); + str = str.substr(0, str.length - 1); + + let lines = JSON.parse(`[${str}]`); + lines = lines.map(l => `[${l[0]}] ${l[1]} - ${l[2]}`); + log(`${lines.join('\n')}\n`); + return res(); + })); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..46fffbe --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "lib": ["es2017"] + }, + "files": ["src/index.d.ts"] + } \ No newline at end of file