From c1db61fecd7294981f68d3d8126218354d8cd383 Mon Sep 17 00:00:00 2001 From: aididan20 <45387741+aididan20@users.noreply.github.com> Date: Sat, 13 Feb 2021 21:52:23 +0100 Subject: [PATCH] Upload --- .dockerignore | 4 + .eslintignore | 9 + .eslintrc.json | 108 ++++ .gitignore | 50 ++ .gitmodules | 3 + .npmrc | 1 + Dockerfile | 23 + LICENSE.md | 402 ++++++++++++ README.md | 1 + config/default.yaml | 123 ++++ config/development.yaml | 34 + config/production.yaml | 131 ++++ gulpfile.js | 37 ++ package.json | 79 +++ pm2/dyno.bot.json | 11 + scripts/convertConfig.js | 17 + scripts/execAll.sh | 8 + scripts/loadActivity.js | 105 ++++ scripts/reshard.js | 101 +++ src/commands/Admin/Avatar.js | 37 ++ src/commands/Admin/Changelog.js | 28 + src/commands/Admin/CommandStats.js | 65 ++ src/commands/Admin/Data.js | 730 ++++++++++++++++++++++ src/commands/Admin/DisablePremium.js | 130 ++++ src/commands/Admin/EnablePremium.js | 107 ++++ src/commands/Admin/Eval.js | 74 +++ src/commands/Admin/Exec.js | 50 ++ src/commands/Admin/Git.js | 50 ++ src/commands/Admin/GlobalDisable.js | 50 ++ src/commands/Admin/GlobalEnable.js | 50 ++ src/commands/Admin/Guild.js | 167 +++++ src/commands/Admin/ListingBlacklist.js | 40 ++ src/commands/Admin/LoadCommand.js | 53 ++ src/commands/Admin/LoadController.js | 23 + src/commands/Admin/LoadIPC.js | 35 ++ src/commands/Admin/LoadModule.js | 27 + src/commands/Admin/MoveCluster.js | 47 ++ src/commands/Admin/Partner.js | 39 ++ src/commands/Admin/RLReset.js | 68 ++ src/commands/Admin/RemoteDebug.js | 70 +++ src/commands/Admin/RemoteDiagnose.js | 121 ++++ src/commands/Admin/Restart.js | 63 ++ src/commands/Admin/Sessions.js | 69 ++ src/commands/Admin/Speedtest.js | 33 + src/commands/Admin/UnloadModule.js | 27 + src/commands/Admin/Update.js | 53 ++ src/commands/Admin/UpdateTeam.js | 94 +++ src/commands/Admin/Username.js | 25 + src/commands/Info/Info.js | 76 +++ src/commands/Info/Ping.js | 30 + src/commands/Info/Premium.js | 47 ++ src/commands/Info/Stats.js | 107 ++++ src/commands/Info/Uptime.js | 37 ++ src/commands/Misc/Avatar.js | 40 ++ src/commands/Misc/Botlist.js | 75 +++ src/commands/Misc/Color.js | 41 ++ src/commands/Misc/Discrim.js | 35 ++ src/commands/Misc/Distance.js | 64 ++ src/commands/Misc/DynoAvatar.js | 41 ++ src/commands/Misc/Emotes.js | 52 ++ src/commands/Misc/InviteInfo.js | 94 +++ src/commands/Misc/MemberCount.js | 65 ++ src/commands/Misc/RandomColor.js | 37 ++ src/commands/Misc/ServerInfo.js | 68 ++ src/commands/Misc/Whois.js | 128 ++++ src/commands/Roles/RoleInfo.js | 60 ++ src/commands/Roles/Roles.js | 64 ++ src/core/Dyno.js | 575 +++++++++++++++++ src/core/RPCClient.js | 15 + src/core/RPCServer.js | 125 ++++ src/core/cluster/Cluster.js | 145 +++++ src/core/cluster/Events.js | 194 ++++++ src/core/cluster/Logger.js | 129 ++++ src/core/cluster/Manager.js | 185 ++++++ src/core/cluster/Server.js | 164 +++++ src/core/cluster/Sharding.js | 226 +++++++ src/core/clusterManager/Commands.js | 161 +++++ src/core/clusterManager/Logger.js | 121 ++++ src/core/clusterManager/Manager.js | 184 ++++++ src/core/collections/CommandCollection.js | 111 ++++ src/core/collections/GuildCollection.js | 424 +++++++++++++ src/core/collections/ModuleCollection.js | 203 ++++++ src/core/config.js | 108 ++++ src/core/database.js | 24 + src/core/logger.js | 98 +++ src/core/managers/EventManager.js | 278 ++++++++ src/core/managers/IPCManager.js | 146 +++++ src/core/managers/LangManager.js | 348 +++++++++++ src/core/managers/PagerManager.js | 37 ++ src/core/managers/PermissionsManager.js | 105 ++++ src/core/managers/RPCManager.js | 147 +++++ src/core/managers/WebhookManager.js | 93 +++ src/core/matomo.js | 497 +++++++++++++++ src/core/metrics.js | 182 ++++++ src/core/processManager/Manager.js | 146 +++++ src/core/processManager/Process.js | 99 +++ src/core/redis.js | 36 ++ src/core/rpc/Client.js | 14 + src/core/rpc/LogServer.js | 44 ++ src/core/rpc/Server.js | 13 + src/core/rpc/index.js | 3 + src/core/statsd.js | 17 + src/core/transports/winston-sentry.js | 121 ++++ src/core/utils/Diagnostics.js | 201 ++++++ src/events/channelCreate.js | 17 + src/events/channelDelete.js | 17 + src/events/guildBanAdd.js | 17 + src/events/guildBanRemove.js | 17 + src/events/guildMemberAdd.js | 17 + src/events/guildMemberRemove.js | 17 + src/events/guildMemberUpdate.js | 18 + src/events/guildRoleCreate.js | 17 + src/events/guildRoleDelete.js | 17 + src/events/guildRoleUpdate.js | 18 + src/events/messageCreate.js | 36 ++ src/events/messageDelete.js | 18 + src/events/messageDeleteBulk.js | 19 + src/events/messageReactionAdd.js | 33 + src/events/messageReactionRemove.js | 33 + src/events/messageReactionRemoveAll.js | 29 + src/events/messageUpdate.js | 20 + src/events/voiceChannelJoin.js | 18 + src/events/voiceChannelLeave.js | 18 + src/events/voiceChannelSwitch.js | 19 + src/index.d.ts | 363 +++++++++++ src/index.js | 6 + src/ipc/cfgset.js | 25 + src/ipc/disconnectShard.js | 11 + src/ipc/discrim.js | 20 + src/ipc/ping.js | 5 + src/ipc/shards.js | 25 + src/ipc/shared.js | 14 + src/ipc/stats.js | 22 + src/ipc/unload.js | 16 + src/logo.txt | 6 + src/modules/AdminHandler.js | 83 +++ src/modules/Carbon.js | 89 +++ src/modules/CommandHandler.js | 403 ++++++++++++ src/modules/CoordsChannel.js | 91 +++ src/modules/Dyno.js | 607 ++++++++++++++++++ src/modules/DynoManager.js | 37 ++ src/modules/Premium.js | 76 +++ src/modules/ShardStatus.js | 168 +++++ src/modules/Starboard.js | 87 +++ src/modules/modules.js | 35 ++ src/start.js | 132 ++++ tsconfig.json | 18 + 147 files changed, 13306 insertions(+) create mode 100644 .dockerignore create mode 100644 .eslintignore create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .npmrc create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 config/default.yaml create mode 100644 config/development.yaml create mode 100644 config/production.yaml create mode 100644 gulpfile.js create mode 100644 package.json create mode 100644 pm2/dyno.bot.json create mode 100644 scripts/convertConfig.js create mode 100644 scripts/execAll.sh create mode 100644 scripts/loadActivity.js create mode 100644 scripts/reshard.js create mode 100644 src/commands/Admin/Avatar.js create mode 100644 src/commands/Admin/Changelog.js create mode 100644 src/commands/Admin/CommandStats.js create mode 100644 src/commands/Admin/Data.js create mode 100644 src/commands/Admin/DisablePremium.js create mode 100644 src/commands/Admin/EnablePremium.js create mode 100644 src/commands/Admin/Eval.js create mode 100644 src/commands/Admin/Exec.js create mode 100644 src/commands/Admin/Git.js create mode 100644 src/commands/Admin/GlobalDisable.js create mode 100644 src/commands/Admin/GlobalEnable.js create mode 100644 src/commands/Admin/Guild.js create mode 100644 src/commands/Admin/ListingBlacklist.js create mode 100644 src/commands/Admin/LoadCommand.js create mode 100644 src/commands/Admin/LoadController.js create mode 100644 src/commands/Admin/LoadIPC.js create mode 100644 src/commands/Admin/LoadModule.js create mode 100644 src/commands/Admin/MoveCluster.js create mode 100644 src/commands/Admin/Partner.js create mode 100644 src/commands/Admin/RLReset.js create mode 100644 src/commands/Admin/RemoteDebug.js create mode 100644 src/commands/Admin/RemoteDiagnose.js create mode 100644 src/commands/Admin/Restart.js create mode 100644 src/commands/Admin/Sessions.js create mode 100644 src/commands/Admin/Speedtest.js create mode 100644 src/commands/Admin/UnloadModule.js create mode 100644 src/commands/Admin/Update.js create mode 100644 src/commands/Admin/UpdateTeam.js create mode 100644 src/commands/Admin/Username.js create mode 100644 src/commands/Info/Info.js create mode 100644 src/commands/Info/Ping.js create mode 100644 src/commands/Info/Premium.js create mode 100644 src/commands/Info/Stats.js create mode 100644 src/commands/Info/Uptime.js create mode 100644 src/commands/Misc/Avatar.js create mode 100644 src/commands/Misc/Botlist.js create mode 100644 src/commands/Misc/Color.js create mode 100644 src/commands/Misc/Discrim.js create mode 100644 src/commands/Misc/Distance.js create mode 100644 src/commands/Misc/DynoAvatar.js create mode 100644 src/commands/Misc/Emotes.js create mode 100644 src/commands/Misc/InviteInfo.js create mode 100644 src/commands/Misc/MemberCount.js create mode 100644 src/commands/Misc/RandomColor.js create mode 100644 src/commands/Misc/ServerInfo.js create mode 100644 src/commands/Misc/Whois.js create mode 100644 src/commands/Roles/RoleInfo.js create mode 100644 src/commands/Roles/Roles.js create mode 100644 src/core/Dyno.js create mode 100644 src/core/RPCClient.js create mode 100644 src/core/RPCServer.js create mode 100644 src/core/cluster/Cluster.js create mode 100644 src/core/cluster/Events.js create mode 100644 src/core/cluster/Logger.js create mode 100644 src/core/cluster/Manager.js create mode 100644 src/core/cluster/Server.js create mode 100644 src/core/cluster/Sharding.js create mode 100644 src/core/clusterManager/Commands.js create mode 100644 src/core/clusterManager/Logger.js create mode 100644 src/core/clusterManager/Manager.js create mode 100644 src/core/collections/CommandCollection.js create mode 100644 src/core/collections/GuildCollection.js create mode 100644 src/core/collections/ModuleCollection.js create mode 100644 src/core/config.js create mode 100644 src/core/database.js create mode 100644 src/core/logger.js create mode 100644 src/core/managers/EventManager.js create mode 100644 src/core/managers/IPCManager.js create mode 100644 src/core/managers/LangManager.js create mode 100644 src/core/managers/PagerManager.js create mode 100644 src/core/managers/PermissionsManager.js create mode 100644 src/core/managers/RPCManager.js create mode 100644 src/core/managers/WebhookManager.js create mode 100644 src/core/matomo.js create mode 100644 src/core/metrics.js create mode 100644 src/core/processManager/Manager.js create mode 100644 src/core/processManager/Process.js create mode 100644 src/core/redis.js create mode 100644 src/core/rpc/Client.js create mode 100644 src/core/rpc/LogServer.js create mode 100644 src/core/rpc/Server.js create mode 100644 src/core/rpc/index.js create mode 100644 src/core/statsd.js create mode 100644 src/core/transports/winston-sentry.js create mode 100644 src/core/utils/Diagnostics.js create mode 100644 src/events/channelCreate.js create mode 100644 src/events/channelDelete.js create mode 100644 src/events/guildBanAdd.js create mode 100644 src/events/guildBanRemove.js create mode 100644 src/events/guildMemberAdd.js create mode 100644 src/events/guildMemberRemove.js create mode 100644 src/events/guildMemberUpdate.js create mode 100644 src/events/guildRoleCreate.js create mode 100644 src/events/guildRoleDelete.js create mode 100644 src/events/guildRoleUpdate.js create mode 100644 src/events/messageCreate.js create mode 100644 src/events/messageDelete.js create mode 100644 src/events/messageDeleteBulk.js create mode 100644 src/events/messageReactionAdd.js create mode 100644 src/events/messageReactionRemove.js create mode 100644 src/events/messageReactionRemoveAll.js create mode 100644 src/events/messageUpdate.js create mode 100644 src/events/voiceChannelJoin.js create mode 100644 src/events/voiceChannelLeave.js create mode 100644 src/events/voiceChannelSwitch.js create mode 100644 src/index.d.ts create mode 100644 src/index.js create mode 100644 src/ipc/cfgset.js create mode 100644 src/ipc/disconnectShard.js create mode 100644 src/ipc/discrim.js create mode 100644 src/ipc/ping.js create mode 100644 src/ipc/shards.js create mode 100644 src/ipc/shared.js create mode 100644 src/ipc/stats.js create mode 100644 src/ipc/unload.js create mode 100644 src/logo.txt create mode 100644 src/modules/AdminHandler.js create mode 100644 src/modules/Carbon.js create mode 100644 src/modules/CommandHandler.js create mode 100644 src/modules/CoordsChannel.js create mode 100644 src/modules/Dyno.js create mode 100644 src/modules/DynoManager.js create mode 100644 src/modules/Premium.js create mode 100644 src/modules/ShardStatus.js create mode 100644 src/modules/Starboard.js create mode 100644 src/modules/modules.js create mode 100644 src/start.js create mode 100644 tsconfig.json 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