├── .eslintrc.json ├── LICENSE ├── README.md ├── examples ├── api.js ├── bot.js ├── examples.md ├── gateway.js ├── rpc_clients.js ├── rpc_server.js └── sharding.js ├── index.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── src ├── clients │ ├── Api │ │ ├── Api.js │ │ ├── package.json │ │ └── structures │ │ │ ├── BaseRequest.js │ │ │ ├── RateLimit.js │ │ │ ├── RateLimitCache.js │ │ │ ├── RateLimitHeaders.js │ │ │ ├── RateLimitMap.js │ │ │ ├── Request.js │ │ │ ├── RequestQueue.js │ │ │ ├── index.js │ │ │ └── tests │ │ │ ├── RateLimit.spec.js │ │ │ └── RequestQueue.spec.js │ ├── Gateway │ │ ├── Gateway.js │ │ ├── package.json │ │ └── structures │ │ │ └── Identity.js │ └── Paracord │ │ ├── Paracord.js │ │ ├── ShardLauncher.js │ │ ├── eventFuncs.js │ │ ├── package.json │ │ └── structures │ │ ├── Guild.js │ │ └── tests │ │ └── Guild.spec.js ├── rpc │ ├── protobufs │ │ ├── identify_lock.proto │ │ ├── rate_limit.proto │ │ └── request.proto │ ├── server │ │ ├── Server.js │ │ └── package.json │ ├── services │ │ ├── common.js │ │ ├── identifyLock │ │ │ ├── IdentifyLockService.js │ │ │ └── callbacks.js │ │ ├── index.js │ │ ├── rateLimit │ │ │ ├── RateLimitService.js │ │ │ └── callbacks.js │ │ └── request │ │ │ ├── RequestService.js │ │ │ └── callbacks.js │ └── structures │ │ ├── identityLock │ │ ├── Lock.js │ │ ├── LockRequestMessage.js │ │ ├── StatusMessage.js │ │ └── TokenMessage.js │ │ ├── index.js │ │ ├── rateLimit │ │ ├── AuthorizationMessage.js │ │ ├── RateLimitStateMessage.js │ │ └── RequestMetaMessage.js │ │ └── request │ │ ├── RequestMessage.js │ │ └── ResponseMessage.js ├── typedefs.js └── utils │ ├── Utils.js │ ├── constants.js │ ├── package.json │ └── tests │ └── Util.spec.js └── tests ├── smoke.spec.js └── suite.spec.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "mocha": true 5 | }, 6 | "extends": [ 7 | "airbnb-base" 8 | ], 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly", 12 | "BigInt": true 13 | }, 14 | "parserOptions": { 15 | "sourceType": "script", 16 | "ecmaFeatures": { 17 | "modules": false 18 | } 19 | }, 20 | "rules": { 21 | "no-underscore-dangle": "off", 22 | "no-bitwise": "off", 23 | "no-plusplus": "off", 24 | "no-restricted-syntax": "off", 25 | "no-unused-expressions": "off", 26 | "no-param-reassign": "off", 27 | "global-require": "off", 28 | "no-use-before-define": "off", 29 | "strict": [ 30 | 2, 31 | "global" 32 | ], 33 | "max-len": "warn", 34 | "camelcase": "warn", 35 | "prefer-const": "warn", 36 | "no-await-in-loop": "warn" 37 | }, 38 | "settings": { 39 | "import/core-modules": [ 40 | "axios", 41 | "@grpc/grpc-js", 42 | "@grpc/proto-loader", 43 | "grpc", 44 | "pm2", 45 | "ws" 46 | ] 47 | } 48 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2020 Landeau McDade 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paracord 2 | 3 | ## Table of Contents 4 | 5 | - [About](#about) 6 | - [Features](#features) 7 | - [Getting Started](#getting-started) 8 | - [Requirements](#requirements) 9 | - [Installing](#installing) 10 | - [Optional Dependencies](#optional-dependencies) 11 | - [Examples](#examples) 12 | - [Missing Features](#missing-features) 13 | - [Contributing](#contributing) 14 | - [Licensing](#licensing) 15 | - [Links](#links) 16 | 17 | --- 18 | 19 | ## About 20 | 21 | A highly-scalable NodeJS framework built to interact with [Discord's API](https://discordapp.com/developers/docs/intro). 22 | 23 | Paracord addresses an important problem that large bot owners will inevitably encounter, how to avoid the exponential costs of vertically scaling infrastructure in the cloud while maintaining high reliability and availability. Paracord solves this by utilizing clients and servers running [grpc](https://grpc.io/) to support limited interprocess communication between shards on remote hosts. Bots of all sizes can use this framework to get started and seamlessly transition to multi-shard and eventually multi-host configurations with the addition of just a few lines of code. 24 | 25 | NOT YET READY FOR PRODUCTION. If you choose to use the modules in this library, understand that nothing here is set in stone and breaking changes may occur without warning. 26 | 27 | --- 28 | 29 | ## Features 30 | 31 | - Native horizontal scaling 32 | - Internal sharding 33 | - Fast and efficient inter-host communication with [grpc](https://grpc.io/) 34 | - Leverages [pm2](https://pm2.keymetrics.io/) for additional shard separation and log management 35 | - Modularized REST and Gateway clients 36 | - Optional remote rate limit handling 37 | - Limited abstractions, work closer to the API 38 | 39 | --- 40 | 41 | ## Getting started 42 | 43 | ### Requirements 44 | 45 | Paracord requires NodeJS 10.17+. 46 | 47 | If you plan to use the Shard Launcher, a global installation of [pm2](https://pm2.keymetrics.io/) is required. This is not true for internal sharding. To install pm2 globally, run the following command: 48 | 49 | ```shell 50 | npm install pm2 -g 51 | ``` 52 | 53 | ### Installing 54 | 55 | Getting started with any of the clients is as simple as installing the package into your project and importing the client you want. 56 | 57 | ```shell 58 | npm install paracord 59 | ``` 60 | 61 | ### Optional Dependencies 62 | 63 | Optional dependencies should be installed with the package. 64 | However, if you choose to not install them then here is what to know. 65 | 66 | For sharding, the pm2 npm package will need to be installed to your project: 67 | 68 | ```shell 69 | npm install pm2 70 | ``` 71 | 72 | If you plan to use the rpc services, you will need to install the grpc packages in the command below: 73 | 74 | ```shell 75 | npm install grpc@1.24.2 @grpc/grpc-js@0.6.15 @grpc/proto-loader@0.5.3 76 | ``` 77 | 78 | --- 79 | 80 | ## Examples 81 | 82 | Importing a client into your project is easy. 83 | 84 | ```javascript 85 | // This is an example. You can find more in the examples directory of this repo. 86 | const { Paracord } = require("paracord"); 87 | ``` 88 | 89 | The available clients are `Paracord`, `Api`, `Gateway`, `ShardLauncher`, and `Server`. In a generalized bot scenario, you will be using the `Paracord` client. 90 | 91 | Examples with each client can be found in [docs/examples](examples/examples.md). 92 | 93 | --- 94 | 95 | ## Missing Features 96 | 97 | At the moment, Paracord is missing some functionality that is common in Discord frameworks. they are listed here: 98 | 99 | - Voice handling 100 | - Connection compression 101 | 102 | --- 103 | 104 | ## Contributing 105 | 106 | Right now there is no formal process for contributing. If you wish to start making contributions, please reach out to me, Lando#7777, first. You can find me in the [Paracord Discord](https://discord.gg/EBp3GCm). 107 | 108 | --- 109 | 110 | ## Licensing 111 | 112 | The code in this project is licensed under the [Apache 2.0 license](LICENSE). 113 | 114 | --- 115 | 116 | ## Links 117 | 118 | - Discord Server: https://discord.gg/EBp3GCm 119 | - Repository: https://github.com/paracordjs/paracord 120 | - Issue tracker: https://github.com/paracordjs/paracord/issues 121 | - In case of sensitive bugs like security vulnerabilities, please contact 122 | paracord@opayq.com directly instead of using issue tracker. We value your effort 123 | to improve the security and privacy of this project! 124 | - Related projects: 125 | - Statbot: https://statbot.net/ 126 | -------------------------------------------------------------------------------- /examples/api.js: -------------------------------------------------------------------------------- 1 | /* Making a request with the Paracord client is the same as with the Api client. */ 2 | 3 | 'use strict'; 4 | 5 | const { Api } = require('paracord'); 6 | 7 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 8 | const api = new Api(token); 9 | 10 | const method = 'GET'; 11 | const endpoint = '/channels/123456789'; // https://discordapp.com/developers/docs/resources/channel 12 | 13 | /* With promise chain. */ 14 | api.request(method, endpoint).then((res) => { 15 | if (res.status === 200) { 16 | console.log(res.data); 17 | } else { 18 | throw Error('Bad response.'); 19 | } 20 | }); 21 | 22 | /* With async/await. */ 23 | async function main() { 24 | const res = await api.request(method, endpoint); 25 | if (res.status === 200) { 26 | console.log(res.data); 27 | } else { 28 | throw Error('Bad response.'); 29 | } 30 | } 31 | main(); 32 | -------------------------------------------------------------------------------- /examples/bot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Paracord } = require('paracord'); 4 | 5 | /* Simple bot and log in. */ 6 | { 7 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 8 | const bot = new Paracord(token); 9 | 10 | bot.on('PARACORD_STARTUP_COMPLETE', () => { 11 | console.log('Hello world!'); 12 | }); 13 | 14 | bot.login(); 15 | } 16 | 17 | /* You can provide an object of custom names for events. */ 18 | { 19 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 20 | const clientOptions = { 21 | events: { PARACORD_STARTUP_COMPLETE: 'ready' }, // key (original event name): value (name you want emitted) 22 | }; 23 | const bot = new Paracord(token, clientOptions); 24 | 25 | bot.on('ready', () => { 26 | console.log('Hello world!'); 27 | }); 28 | 29 | bot.login(); 30 | } 31 | 32 | /* For internal sharding, provide the shards and shard count as parameters to the login(). 33 | The PARACORD_STARTUP_COMPLETE event will be emitted when all shards have logged in for the first time. */ 34 | { 35 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 36 | const bot = new Paracord(token); 37 | 38 | bot.on('PARACORD_STARTUP_COMPLETE', () => { 39 | console.log('All internal shards have successfully logged in!'); 40 | }); 41 | 42 | const shards = [0, 1, 2]; 43 | const shardCount = 3; 44 | bot.login({ shards, shardCount }); 45 | } 46 | 47 | /* Emit events during start up by passing `allowEventDuringStartup` to login. */ 48 | { 49 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 50 | const bot = new Paracord(token); 51 | 52 | bot.on('GUILD_CREATE', () => { 53 | console.log('This event may have been sent during startup.'); 54 | }); 55 | 56 | bot.login({ allowEventsDuringStartup: true }); 57 | } 58 | 59 | /* Provide an identity object that will be cloned to each internal shard. 60 | (`properties` details will be overwritten.) */ 61 | { 62 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 63 | const bot = new Paracord(token); 64 | 65 | const identity = { 66 | presence: { 67 | game: { 68 | name: 'a game.', 69 | type: 0, 70 | }, 71 | status: 'dnd', 72 | afk: false, 73 | }, 74 | large_threshold: 250, 75 | intents: 32768, 76 | }; 77 | 78 | bot.login({ identity }); 79 | } 80 | 81 | /* Making a request with the Paracord client is the same as the Api client. */ 82 | { 83 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 84 | const bot = new Paracord(token); 85 | 86 | const method = 'GET'; 87 | const endpoint = '/channels/123456789'; // https://discordapp.com/developers/docs/resources/channel 88 | 89 | bot.request(method, endpoint).then((res) => { 90 | if (res.status === 200) { 91 | console.log(res.data); 92 | } else { 93 | throw Error('Bad response.'); 94 | } 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /examples/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | The following are some examples of how to use the various clients. 4 | 5 | - [Bot (Paracord)](bot.js) 6 | - [Sharding](sharding.js) 7 | - [Api](api.js) 8 | - [Gateway](gateway.js) 9 | - [Rpc Server](rpc_server.js) 10 | - [Rpc Client](rpc_clients.js) 11 | -------------------------------------------------------------------------------- /examples/gateway.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | All Discord events can be found in the docs. They will be in all caps and spaces will be replaced with underlines. 5 | https://discordapp.com/developers/docs/topics/gateway#commands-and-events-gateway-events 6 | */ 7 | 8 | /* No emitter in options. */ 9 | { 10 | const { Gateway } = require('paracord'); 11 | 12 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 13 | 14 | const gateway = new Gateway(token); 15 | 16 | async function main() { 17 | /* If you do not provide an emitter through the GatewayOptions, one will be created and returned by `login()`. */ 18 | const emitter = await gateway.login(); 19 | 20 | emitter.on('READY', (data) => { 21 | console.log('Ready packet received.'); 22 | console.log(data); 23 | }); 24 | emitter.on('GUILD_CREATE', (data) => { 25 | console.log('Guild create packet received.'); 26 | console.log(data); 27 | }); 28 | } 29 | 30 | main(); 31 | } 32 | 33 | /* Emitter in options. */ 34 | { 35 | const { EventEmitter } = require('events'); 36 | const { Gateway } = require('paracord'); 37 | 38 | const emitter = new EventEmitter(); 39 | emitter.on('READY', (data) => { 40 | console.log('Ready packet received.'); 41 | console.log(data); 42 | }); 43 | emitter.on('GUILD_CREATE', (data) => { 44 | console.log('Guild create packet received.'); 45 | console.log(data); 46 | }); 47 | 48 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 49 | const options = { emitter }; 50 | const gateway = new Gateway(token, options); 51 | 52 | /* If you do not provide an emitter through the GatewayOptions, one will be created and returned by `login()`. */ 53 | gateway.login(); 54 | } 55 | -------------------------------------------------------------------------------- /examples/rpc_clients.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | ************************ 5 | *********GATEWAY******** 6 | ************************ 7 | */ 8 | { 9 | const { Gateway } = require('paracord'); 10 | 11 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 12 | const gateway = new Gateway(token); 13 | /* 14 | Connect to a server and set the main lock duration to 5000 milliseconds (5 seconds). 15 | When acquiring the lock, it will remaining locked for the duration. 16 | The main lock will not be unlocked by this client or Paracord. 17 | `allowFallback` will ignore the lock if the client cannot reach the server. defaults to `true`. 18 | */ 19 | gateway.addIdentifyLockServices({ duration: 5000, allowFallback: true }); 20 | gateway.login(); 21 | } 22 | 23 | { 24 | const { Gateway } = require('paracord'); 25 | 26 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 27 | const gateway = new Gateway(token); 28 | /* 29 | Additional locks can be passed and will be acquired in succession when identifying. 30 | The main lock will be acquired last since it will not be unlocked by Paracord. 31 | */ 32 | gateway.addIdentifyLockServices( 33 | { 34 | // acquired third 35 | duration: 5000, // using default host:port (127.0.0.1:50051) 36 | }, 37 | { 38 | // acquired first 39 | host: '127.0.0.1', port: 50052, duration: 10000, 40 | }, 41 | { 42 | // acquired second 43 | host: '127.0.0.1', port: 50053, duration: 20000, 44 | }, 45 | ); 46 | gateway.login(); 47 | } 48 | 49 | { 50 | const { Gateway } = require('paracord'); 51 | 52 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 53 | const gateway = new Gateway(token); 54 | /* 55 | Pass in `null` as the first parameter to not set the main lock. 56 | */ 57 | gateway.addIdentifyLockServices( 58 | null, 59 | { 60 | // only lock 61 | host: '127.0.0.1', port: 50052, duration: 10000, 62 | }, 63 | 64 | ); 65 | gateway.login(); 66 | } 67 | 68 | /* 69 | Provide a host/port to point to services to point to a different server than default. 70 | gateway.addIdentifyLockService({ 71 | duration: 5000, 72 | host: '127.0.0.1', // default host 73 | port: 50051 // default port 74 | }); 75 | */ 76 | 77 | /* 78 | For the addIdentifyLockService, provide additional locks as parameters. They will be acquired in order defined. 79 | gateway.addIdentifyLockService( 80 | { 81 | duration: 5000, 82 | host: '127.0.0.1', 83 | port: 50051, 84 | }, 85 | { 86 | duration: 10000, 87 | host: '127.0.0.1', 88 | port: 50052, 89 | }, 90 | ); 91 | */ 92 | 93 | /* 94 | ************************ 95 | **********API*********** 96 | ************************ 97 | */ 98 | { 99 | const { Api } = require('paracord'); 100 | 101 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 102 | const api = new Api(token); 103 | 104 | /* `allowFallback` defaults to `true`. */ 105 | api.addRateLimitService({ allowFallback: true }); // Only one of these services may be added to a client. 106 | // api.addRequestService(); 107 | 108 | api.request('GET', '/channels/123456789').then((res) => { 109 | if (res.status === 200) { 110 | console.log(res.data); 111 | } else { 112 | throw Error('Bad response.'); 113 | } 114 | }); 115 | } 116 | 117 | /* 118 | ************************ 119 | ********PARACORD******** 120 | ************************ 121 | */ 122 | { 123 | const { Paracord } = require('paracord'); 124 | 125 | const token = 'myBotToken'; // https://discordapp.com/developers/applications/ 126 | const bot = new Paracord(token); 127 | 128 | bot.on('PARACORD_STARTUP_COMPLETE', () => { 129 | console.log('Hello world!'); 130 | }); 131 | /* Same as the Gateway and Api examples above. */ 132 | bot.addRateLimitService({ allowFallback: true }); 133 | // bot.addRequestService(); 134 | bot.addIdentifyLockService({ duration: 5000, allowFallback: true }); 135 | 136 | bot.login(); 137 | } 138 | -------------------------------------------------------------------------------- /examples/rpc_server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { EventEmitter } = require('events'); 4 | const { Server } = require('../../index'); 5 | 6 | const logEmitter = new EventEmitter(); 7 | logEmitter.on('DEBUG', (event) => console.log(event)); 8 | 9 | const serverOptions = { emitter: logEmitter }; 10 | /* 11 | const serverOptions = { 12 | emitter: logEmitter, 13 | host: "127.0.0.1", 14 | port: "50051" 15 | }; 16 | */ 17 | 18 | /* Provides logging output for a resultant api client. */ 19 | const apiOptions = { emitter: logEmitter }; 20 | const token = 'myBotToken'; 21 | 22 | const server = new Server(serverOptions); 23 | 24 | /* Add whichever services this server should handle. */ 25 | server.addRequestService(token, apiOptions); // Sends requests on behalf of the client. 26 | server.addRateLimitService(token, apiOptions); // Caches rate limits and authorizes requests. 27 | server.addLockService(); // Provides mutexes for gateway clients sending `identify` payloads. 28 | 29 | /* Begin serving the request. */ 30 | server.serve(); 31 | -------------------------------------------------------------------------------- /examples/sharding.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* Spawn shards internally on a single process. */ 4 | { 5 | const { ShardLauncher } = require('../index'); 6 | 7 | const main = './path/to/bot/entry/file'; 8 | 9 | const shardsToSpawn = [0, 1]; 10 | const totalShards = 2; 11 | 12 | const launcher = new ShardLauncher(main, { 13 | shardIds: shardsToSpawn, 14 | shardCount: totalShards, 15 | }); 16 | 17 | launcher.launch(); 18 | } 19 | 20 | /* Spawn shards internally in separate "chunks", each chunk receiving its own pm2 process. */ 21 | { 22 | const { ShardLauncher } = require('../index'); 23 | 24 | const main = './path/to/bot/entry/file'; 25 | 26 | const shardsToSpawn = [[0, 1], [2, 3], [4, 5]]; 27 | const totalShards = 6; 28 | 29 | const launcher = new ShardLauncher(main, { 30 | shardChunks: shardsToSpawn, 31 | shardCount: totalShards, 32 | }); 33 | 34 | launcher.launch(); 35 | } 36 | 37 | /* 38 | From here, pm2 will spawn each shard in shardIds into its own process in pm2. 39 | You can view a list of these by running `pm2 l` on the cli. 40 | */ 41 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | Gateway: require('./src/clients/Gateway/Gateway'), 5 | Api: require('./src/clients/Api/Api'), 6 | Paracord: require('./src/clients/Paracord/Paracord'), 7 | ShardLauncher: require('./src/clients/Paracord/ShardLauncher'), 8 | Server: require('./src/rpc/server/Server'), 9 | }; 10 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.js"] 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paracord", 3 | "version": "0.0.3", 4 | "description": "Scalable Discord gateway and API handlers alongside a pre-built client.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha tests/smoke.spec.js tests/suite.spec.js '**/tests/*.spec.js' --exit", 8 | "test-nodemon": "nodemon --exec \"mocha tests/smoke.spec.js tests/suite.spec.js 'src/**/tests/*.spec.js'\"", 9 | "test-paracord": "nodemon --exec \"mocha tests/smoke.spec.js tests/suite.spec.js 'src/**/tests/*.spec.js' -g Paracord\"", 10 | "coverage": "nodemon --exec \"nyc --reporter=html --reporter=text npm run test\"" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/paracordjs/paracord.git" 15 | }, 16 | "author": "Landeau McDade", 17 | "license": "Apache-2.0", 18 | "bugs": { 19 | "url": "https://github.com/paracordjs/paracord/issues" 20 | }, 21 | "dependencies": { 22 | "axios": "^0.19.0", 23 | "ws": "^7.2.1" 24 | }, 25 | "devDependencies": { 26 | "mocha": "^6.2.1", 27 | "nyc": "^14.1.1", 28 | "sinon": "^7.5.0", 29 | "eslint": "^6.8.0", 30 | "eslint-config-airbnb-base": "^14.0.0", 31 | "eslint-plugin-import": "^2.20.1" 32 | }, 33 | "optionalDependencies": { 34 | "@grpc/grpc-js": "^0.6.15", 35 | "@grpc/proto-loader": "^0.5.3", 36 | "grpc": "^1.24.2", 37 | "pm2": "^3.5.2" 38 | }, 39 | "keywords": [ 40 | "discord", 41 | "discordapp", 42 | "api", 43 | "bot", 44 | "client", 45 | "node", 46 | "paracord" 47 | ] 48 | } -------------------------------------------------------------------------------- /src/clients/Api/Api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const axios = require('axios'); 4 | const Utils = require('../../utils/Utils'); 5 | const { RequestService, RateLimitService } = require('../../rpc/services'); 6 | const { 7 | RateLimitCache, 8 | Request, 9 | RequestQueue, 10 | RateLimitHeaders, 11 | } = require('./structures'); 12 | 13 | const { 14 | LOG_LEVELS, 15 | LOG_SOURCES, 16 | DISCORD_API_URL, 17 | DISCORD_API_DEFAULT_VERSION, 18 | } = require('../../utils/constants'); 19 | 20 | /** @typedef {import("./structures/Request")} Request */ 21 | /** @typedef {import("../../rpc/")} Response */ 22 | 23 | /** A client used to interact with Discord's REST API and navigate its rate limits. */ 24 | module.exports = class Api { 25 | /** 26 | * Creates a new Api client. 27 | * 28 | * @param {string} token Discord token. Will be coerced into a bot token. 29 | * @param {ApiOptions} [options={}] Optional parameters for this handler. 30 | */ 31 | constructor(token, options = {}) { 32 | /** @type {RateLimitCache} Contains rate limit state information. For use when not using rpc; or in fallback. */ 33 | this.rateLimitCache; 34 | /** @type {RequestQueue} Rate limited requests queue. For use when not using rpc; or in fallback, */ 35 | this.requestQueue; 36 | /** @type {NodeJS.Timer} Interval for processing rate limited requests on the queue. */ 37 | this.requestQueueProcessInterval; 38 | 39 | /** @type {RequestService} When using Rpc, the service through which to pass requests to the server. */ 40 | this.rpcRequestService; 41 | /** @type {RateLimitService} When using Rpc, the service through which to get authorization to make requests. */ 42 | this.rpcRateLimitService; 43 | /** @type {boolean} Whether or not this client should handle requests locally for as long as it cannot connect to the rpc server. */ 44 | this.allowFallback; 45 | 46 | /** @type {Object} Key:Value mapping DISCORD_EVENT to user's preferred emitted value. */ 47 | this.events; 48 | /** @type {import("events").EventEmitter} */ 49 | this.emitter; 50 | 51 | this.constructorDefaults(token, options); 52 | } 53 | 54 | /* 55 | ******************************** 56 | ********* CONSTRUCTOR ********** 57 | ******************************** 58 | */ 59 | 60 | /** 61 | * Assigns default values to this Api instance. 62 | * @private 63 | * 64 | * @param {string} token Discord token. 65 | * @param {ApiOptions} [options={}] Optional parameters for this handler. 66 | */ 67 | constructorDefaults(token, options) { 68 | Api.validateParams(token, options); 69 | Object.assign(this, options); 70 | this.rateLimitCache = new RateLimitCache(); 71 | this.requestQueue = new RequestQueue(this.rateLimitCache, this); 72 | 73 | const botToken = Utils.coerceTokenToBotLike(token); 74 | this.createWrappedRequest(botToken); 75 | } 76 | 77 | /** 78 | * Throws errors and warnings if the parameters passed to the constructor aren't sufficient. 79 | * @private 80 | * 81 | * @param {string} token Discord bot token. 82 | */ 83 | static validateParams(token) { 84 | if (token === undefined) { 85 | throw Error('client requires a bot token'); 86 | } 87 | } 88 | 89 | /** 90 | * Creates an isolated axios instance for use by this REST handler. 91 | * @private 92 | */ 93 | createWrappedRequest(token) { 94 | const instance = axios.create({ 95 | baseURL: `${DISCORD_API_URL}/${DISCORD_API_DEFAULT_VERSION}`, 96 | headers: { 97 | Authorization: token, 98 | 'Content-Type': 'application/json', 99 | 'X-RateLimit-Precision': 'millisecond', 100 | }, 101 | ...(this.requestOptions || {}), 102 | }); 103 | 104 | /** @type {WrappedRequest} `axios.request()` decorated with rate limit handling. */ 105 | this.wrappedRequest = this.rateLimitCache.wrapRequest(instance.request); 106 | } 107 | 108 | /* 109 | ******************************** 110 | *********** INTERNAL *********** 111 | ******************************** 112 | */ 113 | 114 | /** 115 | * Simple alias for logging events emitted by this client. 116 | * @private 117 | * 118 | * @param {string} level Key of the logging level of this message. 119 | * @param {string} message Content of the log 120 | * @param {*} [data] Data pertinent to the event. 121 | */ 122 | log(level, message, data) { 123 | this.emit('DEBUG', { 124 | source: LOG_SOURCES.API, 125 | level: LOG_LEVELS[level], 126 | message, 127 | data, 128 | }); 129 | } 130 | 131 | /** 132 | * Emits all events if `this.events` is undefined; otherwise will emit those defined as keys in `this.events` as the paired value. 133 | * @private 134 | * 135 | * @param {string} type Type of event. (e.g. "DEBUG" or "CHANNEL_CREATE") 136 | * @param {Object} data Data to send with the event. 137 | */ 138 | emit(type, data) { 139 | if (this.emitter !== undefined) { 140 | this.emitter.emit(type, data); 141 | } 142 | } 143 | 144 | /* 145 | ******************************** 146 | ********* RPC SERVICE ********** 147 | ******************************** 148 | */ 149 | 150 | /** 151 | * Adds the service that has a server make requests to Discord on behalf of the client. 152 | * 153 | * @param {ServerOptions} [serverOptions={}] 154 | */ 155 | addRequestService(serverOptions = {}) { 156 | if ( 157 | this.rpcRateLimitService !== undefined 158 | || this.rpcRequestService !== undefined 159 | ) { 160 | throw Error( 161 | 'A rpc service has already been defined for this client. Only one may be added.', 162 | ); 163 | } 164 | 165 | this.rpcRequestService = new RequestService(serverOptions || {}); 166 | this.allowFallback = serverOptions.allowFallback; 167 | 168 | { 169 | const message = `Rpc service created for sending requests remotely. Connected to: ${this.rpcRequestService.target}`; 170 | this.log('INFO', message); 171 | } 172 | 173 | if (!this.allowFallback) { 174 | const message = '`allowFallback` option is not true. Requests will fail when unable to connect to the Rpc server.'; 175 | this.log('WARNING', message); 176 | } 177 | } 178 | 179 | /** 180 | * Adds the service that first checks with a server before making a request to Discord. 181 | * 182 | * @param {ServerOptions} [serverOptions={}] 183 | */ 184 | addRateLimitService(serverOptions = {}) { 185 | if ( 186 | this.rpcRateLimitService !== undefined 187 | || this.rpcRequestService !== undefined 188 | ) { 189 | throw Error( 190 | 'A rpc service has already been defined for this client. Only one may be added.', 191 | ); 192 | } 193 | 194 | this.rpcRateLimitService = new RateLimitService(serverOptions || {}); 195 | this.allowFallback = serverOptions.allowFallback; 196 | 197 | { 198 | const message = `Rpc service created for handling rate limits remotely. Connected to: ${this.rpcRateLimitService.target}`; 199 | this.log('INFO', message); 200 | } 201 | 202 | if (!this.allowFallback) { 203 | const message = '`allowFallback` option is not true. Requests will fail when unable to connect to the Rpc server.'; 204 | this.log('WARNING', message); 205 | } 206 | } 207 | 208 | /* 209 | ******************************** 210 | ******** REQUEST QUEUE ********* 211 | ******************************** 212 | */ 213 | 214 | /** 215 | * Starts the request rate limit queue processing. 216 | * 217 | * @param {number} [interval=1e3] Time between checks in ms. 218 | */ 219 | startQueue(interval = 1e3) { 220 | if (this.requestQueueProcessInterval === undefined) { 221 | this.log('INFO', 'Starting request queue.'); 222 | this.requestQueueProcessInterval = setInterval( 223 | this.requestQueue.process.bind(this.requestQueue), 224 | interval, 225 | ); 226 | } else { 227 | throw Error('request queue already started'); 228 | } 229 | } 230 | 231 | /** Stops the request rate limit queue processing. */ 232 | stopQueue() { 233 | this.log('INFO', 'Stopping request queue.'); 234 | clearInterval(this.requestQueueProcessInterval); 235 | this.requestQueueProcessInterval = undefined; 236 | } 237 | 238 | /** 239 | * Makes a request from the queue. 240 | * 241 | * @param {Request} request Request being made. 242 | */ 243 | sendQueuedRequest(request) { 244 | const message = `Sending queued request: ${request.method} ${request.url}`; 245 | this.log('DEBUG', message, request); 246 | return this.wrappedRequest(request); 247 | } 248 | 249 | /* 250 | ******************************** 251 | *********** REQUEST ************ 252 | ******************************** 253 | */ 254 | 255 | /** 256 | * Sends the request to the rpc server for handling. 257 | * 258 | * @param {Request} request Request being made. 259 | */ 260 | async handleRequestRemote(request) { 261 | this.emit('DEBUG', { 262 | source: LOG_SOURCES.API, 263 | level: LOG_LEVELS.DEBUG, 264 | message: 'Sending request over Rpc to server.', 265 | }); 266 | 267 | let res = {}; 268 | try { 269 | res = await this.rpcRequestService.request(request); 270 | } catch (err) { 271 | if (err.code === 14 && this.allowFallback) { 272 | const message = 'Could not reach RPC server. Falling back to handling request locally.'; 273 | this.log('ERROR', message); 274 | 275 | res = await this.handleRequestLocal(request); 276 | } else { 277 | throw err; 278 | } 279 | } 280 | 281 | return res; 282 | } 283 | 284 | /** 285 | * Makes a request to Discord, handling any ratelimits and returning when a non-429 response is received. 286 | * 287 | * @param {string} method HTTP method of the request. 288 | * @param {string} url Discord endpoint url. (e.g. "/channels/abc123") 289 | * @param {Object} [options] 290 | * @param {Object} [options.data] Data to send with the request. 291 | * @param {Object} [options.headers] Headers to send with the request. "Authorization" and "Content-Type" will override the defaults. 292 | * @param {boolean} [options.local] If true, executes the request locally ignoring any rpc services. Be sure to `startQueue()` to handle rate limited requests. 293 | * @returns {Promise} Response to the request made. 294 | */ 295 | async request(method, url, options = {}) { 296 | const { data, headers, local } = options; 297 | 298 | if (url.startsWith('/')) { 299 | url = url.slice(1); 300 | } 301 | 302 | const request = new Request(method.toUpperCase(), url, { 303 | data, 304 | headers, 305 | }); 306 | 307 | if (this.rpcRequestService === undefined || local) { 308 | return this.handleRequestLocal(request); 309 | } 310 | return this.handleRequestRemote(request); 311 | } 312 | 313 | /** 314 | * Send the request and handle 429's. 315 | * @private 316 | * 317 | * @param {Request} request The request being sent. 318 | * @returns {Object} axios response. 319 | */ 320 | async handleRequestLocal(request) { 321 | if (this.requestQueueProcessInterval === undefined) { 322 | const message = 'Making a request with a local Api client without a running request queue. Please invoke `startQueue()` on this client so that rate limits may be handled.'; 323 | this.log('WARNING', message); 324 | } 325 | 326 | // TODO(lando): Review 429-handling logic. This loop may be stacking calls. 327 | 328 | let response = await this.sendRequest(request); 329 | const rateLimitHeaders = RateLimitHeaders.extractRateLimitFromHeaders( 330 | response.headers, 331 | ); 332 | 333 | while (response.status === 429) { 334 | if (this.requestQueueProcessInterval === undefined) { 335 | const message = 'A request has been rate limited and will not be processed. Please invoke `startQueue()` on this client so that rate limits may be handled.'; 336 | this.log('WARNING', message); 337 | } 338 | response = await this.handleRateLimitedRequest(request, rateLimitHeaders); 339 | } 340 | 341 | this.updateRateLimitCache(request, rateLimitHeaders); 342 | 343 | return response; 344 | } 345 | 346 | /** 347 | * Updates the local rate limit cache and sends an update to the server if there is one. 348 | * @private 349 | * 350 | * @param {Request} request The request made. 351 | * @param {RateLimitHeaders} rateLimitHeaders Headers from the response. 352 | */ 353 | updateRateLimitCache(request, rateLimitHeaders) { 354 | this.rateLimitCache.update(request, rateLimitHeaders); 355 | 356 | if ( 357 | this.rpcRateLimitService !== undefined 358 | && rateLimitHeaders !== undefined 359 | ) { 360 | this.updateRpcCache(request, rateLimitHeaders); 361 | } 362 | } 363 | 364 | async updateRpcCache(request, rateLimitHeaders) { 365 | try { 366 | await this.rpcRateLimitService.update( 367 | request, 368 | ...rateLimitHeaders.rpcArgs, 369 | ); 370 | } catch (err) { 371 | if (err.code !== 14) { 372 | throw err; 373 | } 374 | } 375 | } 376 | 377 | /** 378 | * Determines how the request will be made based on the client's options and makes it. 379 | * @private 380 | * 381 | * @param {Request} request Request being made, 382 | */ 383 | async sendRequest(request) { 384 | if (await this.returnOkToMakeRequest(request)) { 385 | const message = `Sending request: ${request.method} ${request.url}`; 386 | this.log('DEBUG', message, request); 387 | return this.wrappedRequest(request); 388 | } 389 | const message = `Enqueuing request: ${request.method} ${request.url}`; 390 | this.log('DEBUG', message, request); 391 | return this.enqueueRequest(request); 392 | } 393 | 394 | /** 395 | * Checks request against relevant service or cache to see if it will trigger a rate limit. 396 | * @param {Request} request Request being made. 397 | * @returns {boolean} `true` if request will not trigger a rate limit. 398 | */ 399 | async returnOkToMakeRequest(request) { 400 | if (this.rpcRateLimitService !== undefined) { 401 | return this.authorizeRequestWithServer(request); 402 | } 403 | return !this.rateLimitCache.returnIsRateLimited(request); 404 | } 405 | 406 | /** 407 | * Gets authorization from the server to make the request. 408 | * @private 409 | * 410 | * @param {Request} request Request being made. 411 | * @returns {boolean} `true` if server has authorized the request. 412 | */ 413 | async authorizeRequestWithServer(request) { 414 | try { 415 | const { resetAfter } = await this.rpcRateLimitService.authorize(request); 416 | 417 | if (resetAfter === 0) { 418 | return true; 419 | } 420 | if ( 421 | request.waitUntil === undefined 422 | || request.waitUntil < new Date().getTime() 423 | ) { 424 | const waitUntil = Utils.timestampNMillisecondsInFuture(resetAfter); 425 | request.assignIfStricterWait(waitUntil); 426 | } 427 | return false; 428 | } catch (err) { 429 | if (err.code === 14 && this.allowFallback) { 430 | const message = 'Could not reach RPC server. Fallback is allowed. Allowing request to be made.'; 431 | this.log('ERROR', message); 432 | 433 | return true; 434 | } 435 | throw err; 436 | } 437 | } 438 | 439 | /** 440 | * Updates the rate limit state and queues the request. 441 | * @private 442 | * 443 | * @param {RateLimitHeaders} headers Response headers. 444 | * @param {Request} request Request being sent. 445 | * @returns {Object} axios response. 446 | */ 447 | handleRateLimitedRequest(request, rateLimitHeaders) { 448 | let message; 449 | if (rateLimitHeaders === undefined || rateLimitHeaders.global) { 450 | message = `Request global rate limited: ${request.method} ${request.url}`; 451 | } else { 452 | message = `Request rate limited: ${request.method} ${request.url}`; 453 | } 454 | 455 | this.log('DEBUG', message, rateLimitHeaders); 456 | 457 | this.updateRateLimitCache(request); 458 | return this.enqueueRequest(request); 459 | } 460 | 461 | /** 462 | * Puts the Api Request onto the queue to be executed when the rate limit has reset. 463 | * @private 464 | * 465 | * @param {import("./structures/Request")} request The Api Request to queue. 466 | * @returns {Promise} Resolves as the response to the request. 467 | */ 468 | enqueueRequest(request) { 469 | // request.timeout = new Date().getTime() + timeout; 470 | 471 | this.requestQueue.push(request); 472 | request.response = undefined; 473 | 474 | /** Continuously checks if the response has returned. */ 475 | function checkRequest(resolve, reject) { 476 | const { response } = request; 477 | if (response !== undefined) { 478 | resolve(response); 479 | } else { 480 | setTimeout(() => checkRequest(resolve, reject)); 481 | } 482 | 483 | // } else if (timeout < new Date().getTime()) { } - keeping this temporarily for posterity 484 | } 485 | 486 | return new Promise(checkRequest); 487 | } 488 | }; 489 | -------------------------------------------------------------------------------- /src/clients/Api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "main": "./Api.js" 4 | } -------------------------------------------------------------------------------- /src/clients/Api/structures/BaseRequest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** Basic information in a request to Discord. */ 4 | module.exports = class BaseRequest { 5 | /** 6 | * Creates a new base request object with its associated rate limit identifiers. 7 | * 8 | * @param {string} method HTTP method of the request. 9 | * @param {string} url Discord REST endpoint target of the request. (e.g. channels/123) 10 | */ 11 | constructor(method, url) { 12 | /** @type {string} HTTP method of the request. */ 13 | this.method = method; 14 | /** @type {string} Discord REST endpoint target of the request. (e.g. channels/123) */ 15 | this.url = BaseRequest.stripUrlLeadingSlash(url); 16 | 17 | /** @type {string} Key generated from the method and minor parameters of a request used internally to get shared buckets. */ 18 | this.rateLimitBucketKey; 19 | /** @type {string} Key for this specific requests rate limit state in the rate limit cache. */ 20 | this.rateLimitKey; 21 | 22 | Object.assign(this, BaseRequest.assignRateLimitMeta(method, url)); 23 | } 24 | 25 | /** 26 | * Standardizes url by stripping the leading `/` if it exists. 27 | * @private 28 | * 29 | * @param {string} url Discord endpoint the request will be sent to. 30 | * @returns {string} A url stripped of leading `/`. 31 | */ 32 | static stripUrlLeadingSlash(url) { 33 | return url.startsWith('/') ? url.replace('/', '') : url; 34 | } 35 | 36 | /** 37 | * Extracts the rate limit information needed to navigate rate limits. 38 | * @private 39 | * 40 | * @param {string} method HTTP method of the request. 41 | * @param {string} url Discord endpoint the request will be sent to. 42 | * @returns An object containing the `rateLimitBucketKey` and `rateLimitKey`. 43 | */ 44 | static assignRateLimitMeta(method, url) { 45 | const [ 46 | rateLimitMajorType, 47 | rateLimitMajorID, 48 | ...rateLimitMinorParameters 49 | ] = url.split('/'); 50 | 51 | const rateLimitBucketKey = BaseRequest.convertMetaToBucketKey( 52 | method, 53 | rateLimitMinorParameters, 54 | ); 55 | 56 | const rateLimitKey = `${rateLimitMajorType}-${rateLimitMajorID}-${rateLimitBucketKey}`; 57 | 58 | return { rateLimitBucketKey, rateLimitKey }; 59 | } 60 | 61 | /** 62 | * Takes the method and url "minor parameters" to create a key used in navigating rate limits. 63 | * @private 64 | * 65 | * @param {string} method HTTP method of the request. 66 | * @param {string} rateLimitMinorParameters Request method and parameters in the url following the major parameter. 67 | * @returns {string} A key used internally to find related buckets. 68 | */ 69 | static convertMetaToBucketKey(method, rateLimitMinorParameters) { 70 | const key = []; 71 | 72 | if (method === 'GET') key.push('ge'); 73 | else if (method === 'POST') key.push('p'); 74 | else if (method === 'PATCH') key.push('u'); 75 | else if (method === 'DELETE') key.push('d'); 76 | 77 | for (const param of rateLimitMinorParameters) { 78 | if (param === 'members') key.push('m'); 79 | else if (param === 'guilds') key.push('gu'); 80 | else if (param === 'channels') key.push('c'); 81 | } 82 | 83 | return key.join('-'); 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/clients/Api/structures/RateLimit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Utils = require('../../../utils'); 4 | 5 | /** State of a Discord rate limit. */ 6 | module.exports = class RateLimit { 7 | /** 8 | * Creates a new rate limit state. 9 | * 10 | * @param {RateLimitState} 11 | */ 12 | constructor({ remaining, resetTimestamp, limit }) { 13 | /** @type {number} Number of requests available before hitting rate limit. Triggers internal rate limiting when 0. */ 14 | this.remaining = remaining; 15 | /** @type {number|void} When the rate limit's remaining requests resets to `limit`. */ 16 | this.resetTimestamp = resetTimestamp; 17 | /** @type {number} From Discord - Rate limit request cap. */ 18 | this.limit = limit; 19 | } 20 | 21 | /** 22 | * If a request can be made without triggering a Discord rate limit. 23 | * @private 24 | * @type {boolean} 25 | */ 26 | get hasRemainingUses() { 27 | return this.remaining > 0; 28 | } 29 | 30 | /** 31 | * If it is past the time Discord said the rate limit would reset. 32 | * @private 33 | * @type {boolean} 34 | */ 35 | get rateLimitHasExpired() { 36 | return this.resetTimestamp <= new Date().getTime(); 37 | } 38 | 39 | /** @type {number} How long until the rate limit resets in ms. */ 40 | get resetAfter() { 41 | const resetAfter = Utils.millisecondsFromNow(this.resetTimestamp); 42 | return resetAfter > 0 ? resetAfter : 0; 43 | } 44 | 45 | /** 46 | * If the request cannot be made without triggering a Discord rate limit. 47 | * @type {boolean} `true` if the rate limit exists and is active. Do no send a request. 48 | */ 49 | get isRateLimited() { 50 | if (this.rateLimitHasExpired) { 51 | this.reset(); 52 | return false; 53 | } if (this.hasRemainingUses) { 54 | return false; 55 | } 56 | return true; 57 | } 58 | 59 | /** Sets the remaining requests back to the known limit. */ 60 | reset() { 61 | this.remaining = this.limit; 62 | } 63 | 64 | /** Reduces the remaining requests before internally rate limiting by 1. */ 65 | decrementRemaining() { 66 | --this.remaining; 67 | } 68 | 69 | /** 70 | * Updates state properties if incoming state is more "strict". 71 | * Strictness is defined by the value that decreases the chance of getting rate limit. 72 | * 73 | * @param {RateLimitState} 74 | */ 75 | assignIfStricter({ remaining, resetTimestamp, limit }) { 76 | if (resetTimestamp !== undefined && remaining < this.remaining) { 77 | this.remaining = remaining; 78 | } 79 | if (resetTimestamp !== undefined && resetTimestamp > this.resetTimestamp) { 80 | this.resetTimestamp = resetTimestamp; 81 | } 82 | if (resetTimestamp !== undefined && limit < this.limit) { 83 | this.limit = limit; 84 | } 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /src/clients/Api/structures/RateLimitCache.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const RateLimitMap = require('./RateLimitMap'); 4 | const RateLimit = require('./RateLimit'); 5 | 6 | // TODO(lando): add a periodic sweep for rate limits to fix potential memory leak. 7 | 8 | /** @typedef {import("./Request")} Request */ 9 | 10 | /** @typedef {string} RateLimitRequestMeta Combination of request parameters that identify a bucket. */ 11 | /** @typedef {string} RateLimitBucket From Discord - A uid that identifies a group of requests that share a rate limit. */ 12 | /** @typedef {string} RateLimitKey */ 13 | 14 | /** 15 | * @typedef {Map} RateLimitMetaToBucket 16 | * `RateLimitBucket` will be `null` if there is no rate limit associated with the request's meta. 17 | */ 18 | 19 | /** 20 | * @typedef {RateLimit} RateLimitTemplate A frozen instance of a rate limit that is used as 21 | * a reference for requests with the same bucket but without an existing cached state. 22 | */ 23 | 24 | /** @typedef {Map} RateLimitTemplateMap */ 25 | 26 | /** Stores the state of all known rate limits this client has encountered. */ 27 | module.exports = class RateLimitCache { 28 | /** Creates a new rate limit cache. */ 29 | constructor() { 30 | /** @type {RateLimitMetaToBucket} Request meta values to their associated rate limit bucket or to `null` if no rate limit for the meta. */ 31 | this.rateLimitMetaToBucket = new Map(); 32 | /** @type {RateLimitMap} Rate limit keys to their associate rate limit. */ 33 | this.rateLimits = new RateLimitMap(); 34 | /** @type {RateLimitTemplateMap} Bucket Ids to saved rate limits state to create new rate limits from known constraints. */ 35 | this.templates = new Map(); 36 | } 37 | 38 | /** 39 | * Decorator for requests. Decrements rate limit when executing if one exists for this request. 40 | * 41 | * @param {Function} requestFunc `request` method of an axios instance. 42 | * @returns {WrappedRequest} Wrapped function. 43 | */ 44 | wrapRequest(requestFunc) { 45 | /** @type {WrappedRequest} */ 46 | const wrappedRequest = (request) => { 47 | const rateLimit = this.getRateLimitFromCache(request); 48 | 49 | if (rateLimit !== undefined) { 50 | rateLimit.decrementRemaining(); 51 | } 52 | 53 | return requestFunc.apply(this, [request.sendData]); 54 | }; 55 | 56 | return wrappedRequest; 57 | } 58 | 59 | /** 60 | * Authorizes a request being check via the rate limit rpc service. 61 | * 62 | * @param {BaseRequest} request Request's rate limit key formed in BaseRequest. 63 | * @returns {number} When the client should wait until before asking to authorize this request again. 64 | */ 65 | authorizeRequestFromClient(request) { 66 | const rateLimit = this.getRateLimitFromCache(request); 67 | if (rateLimit === undefined) { 68 | return 0; 69 | } if (!rateLimit.isRateLimited) { 70 | rateLimit.decrementRemaining(); 71 | return 0; 72 | } 73 | return rateLimit.resetAfter; 74 | } 75 | 76 | /** 77 | * Gets the rate limit, creating a new one from an existing template if the rate limit does not already exist. 78 | * @private 79 | * 80 | * @param {BaseRequest|Request} request Request that may have a rate limit. 81 | * @return {RateLimit} `undefined` when there is no cached rate limit or matching template for this request. 82 | */ 83 | getRateLimitFromCache(request) { 84 | const { rateLimitBucketKey, rateLimitKey } = request; 85 | 86 | const bucket = this.rateLimitMetaToBucket.get(rateLimitBucketKey); 87 | if (bucket !== null && bucket !== undefined) { 88 | const rateLimit = this.rateLimits.get(rateLimitKey); 89 | if (rateLimit !== undefined) { 90 | return rateLimit; 91 | } 92 | return this.createRateLimitFromTemplate(bucket, rateLimitKey); 93 | } 94 | 95 | return undefined; 96 | } 97 | 98 | /** 99 | * Creates a new rate limit from a template if one exists. 100 | * @private 101 | * 102 | * @param {string} bucket Request's bucket Id. 103 | * @param {string} rateLimitKey Request's rate limit key. 104 | * @return {RateLimit} `undefined` when there is no matching template. 105 | */ 106 | createRateLimitFromTemplate(bucket, rateLimitKey) { 107 | const rateLimitTemplate = this.templates.get(bucket); 108 | if (rateLimitTemplate !== undefined) { 109 | return this.rateLimits.upsert(rateLimitKey, rateLimitTemplate); 110 | } 111 | 112 | return undefined; 113 | } 114 | 115 | /** 116 | * Updates this cache using the response headers after making a request. 117 | * 118 | * @param {BaseRequest|Request} request Request that was made. 119 | * @param {RateLimitHeaders} rateLimitHeaders Rate limit values from the response. 120 | */ 121 | update(request, rateLimitHeaders) { 122 | const { rateLimitBucketKey, rateLimitKey } = request; 123 | 124 | if (rateLimitHeaders === undefined) { 125 | this.rateLimitMetaToBucket.set(rateLimitBucketKey, null); 126 | } else { 127 | const { bucket, ...state } = rateLimitHeaders; 128 | 129 | this.rateLimitMetaToBucket.set(rateLimitBucketKey, bucket); 130 | this.rateLimits.upsert(rateLimitKey, state); 131 | this.setTemplateIfNotExists(bucket, state); 132 | } 133 | } 134 | 135 | /** 136 | * Creates a new template if one does not already exist. 137 | * @private 138 | * 139 | * @param {string} rateLimitBucketKey Request's bucket key. 140 | * @param {void|RateLimitState} state State of the rate limit if one exists derived from a set of response headers. 141 | */ 142 | setTemplateIfNotExists(bucket, state) { 143 | if (!this.templates.has(bucket)) { 144 | const template = { 145 | resetTimestamp: undefined, 146 | remaining: state.limit, 147 | limit: state.limit, 148 | }; 149 | this.templates.set(bucket, Object.freeze(new RateLimit(template))); 150 | } 151 | } 152 | 153 | /** 154 | * Runs a request's rate limit meta against the cache to determine if it would trigger a rate limit. 155 | * 156 | * @param {Request} request The request to reference when checking the rate limit state. 157 | * @returns {boolean} `true` if rate limit would get triggered. 158 | */ 159 | returnIsRateLimited(request) { 160 | const rateLimit = this.getRateLimitFromCache(request); 161 | 162 | if (rateLimit !== undefined) { 163 | return rateLimit.isRateLimited; 164 | } 165 | 166 | return false; 167 | } 168 | }; 169 | -------------------------------------------------------------------------------- /src/clients/Api/structures/RateLimitHeaders.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Utils = require('../../../utils/Utils'); 4 | 5 | /** Representation of rate limit values from the header of a response from Discord. */ 6 | module.exports = class RateLimitHeaders { 7 | /** 8 | * Creates a new rate limit headers. 9 | * 10 | * @param {boolean} global From Discord - If the request was globally rate limited. 11 | * @param {string} bucket From Discord - Id of the rate limit bucket. 12 | * @param {number} limit From Discord - Number of requests that can be made between rate limit triggers. 13 | * @param {number} remaining From Discord - Number of requests available before hitting rate limit. 14 | * @param {number} resetAfter From Discord - How long in ms the rate limit resets. 15 | */ 16 | constructor(global, bucket, limit, remaining, resetAfter) { 17 | /** @type {boolean} From Discord - If the request was globally rate limited. */ 18 | this.global = global || false; 19 | /** @type {string} From Discord - Id of the rate limit bucket. */ 20 | this.bucket = bucket; 21 | /** @type {number} From Discord - Number of requests that can be made between rate limit triggers. */ 22 | this.limit = limit; 23 | /** @type {number} From Discord - Number of requests available before hitting rate limit. */ 24 | this.remaining = remaining; 25 | /** @type {number} From Discord - How long in ms the rate limit resets. */ 26 | this.resetAfter = resetAfter; 27 | /** @type {number} A localized timestamp of when the rate limit resets. */ 28 | this.resetTimestamp = Utils.timestampNSecondsInFuture(this.resetAfter); 29 | } 30 | 31 | /** @type {boolean} Whether or not the header values indicate the request has a rate limit. */ 32 | get hasState() { 33 | return this.bucket !== undefined; 34 | } 35 | 36 | /** @type {(string | number | boolean)[]} Values to send over the rate limit service rpc. */ 37 | get rpcArgs() { 38 | return [ 39 | this.global, 40 | this.bucket, 41 | this.limit, 42 | this.remaining, 43 | this.resetAfter, 44 | ]; 45 | } 46 | 47 | /** 48 | * Extracts the rate limit state information if they exist from a set of response headers. 49 | * @private 50 | * 51 | * @param {*} headers Headers from a response. 52 | * @returns {RateLimitHeaders} Rate limit state with the bucket id; or `undefined` if there is no rate limit information. 53 | */ 54 | static extractRateLimitFromHeaders(headers) { 55 | if (headers['x-ratelimit-bucket'] === undefined) { 56 | return undefined; 57 | } 58 | 59 | const { 60 | 'x-ratelimit-bucket': bucket, 61 | 'x-ratelimit-limit': limit, 62 | 'x-ratelimit-remaining': remaining, 63 | 'x-ratelimit-reset-after': resetAfter, 64 | } = headers; 65 | 66 | const global = Object.prototype.hasOwnProperty.call(headers, 'x-ratelimit-global'); 67 | 68 | return new RateLimitHeaders( 69 | global, 70 | bucket, 71 | Number(limit), 72 | Number(remaining), 73 | Number(resetAfter), 74 | ); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /src/clients/Api/structures/RateLimitMap.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const RateLimit = require('./RateLimit'); 4 | 5 | /** @typedef {import("./Request")} Request */ 6 | 7 | /** 8 | * Rate limit keys to their associated state. 9 | * @extends Map 10 | */ 11 | module.exports = class RateLimitMap extends Map { 12 | /** 13 | * Inserts rate limit if not exists. Otherwise, updates its state. 14 | * 15 | * @param {string} rateLimitKey Internally-generated key for this state. 16 | * @param {RateLimitState} state Rate limit state derived from response headers. 17 | * @returns {RateLimit} New / updated rate limit. 18 | */ 19 | upsert(rateLimitKey, state) { 20 | const rateLimit = this.get(rateLimitKey); 21 | 22 | if (rateLimit === undefined) { 23 | this.set(rateLimitKey, new RateLimit(state)); 24 | } else { 25 | rateLimit.assignIfStricter(state); 26 | } 27 | 28 | return rateLimit; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/clients/Api/structures/Request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseRequest = require('./BaseRequest'); 4 | 5 | /** 6 | * A request that will be made to Discord's REST API. 7 | * @extends BaseRequest 8 | */ 9 | module.exports = class Request extends BaseRequest { 10 | /** 11 | * Creates a new request object. 12 | * 13 | * @param {string} method HTTP method of the request. 14 | * @param {string} url Discord REST endpoint target of the request. (e.g. channels/123) 15 | * @param {RequestOptions} [options] Optional parameters for this request. 16 | */ 17 | constructor(method, url, options) { 18 | super(method, url); 19 | /** @type {*} Data to send in the body of the request. */ 20 | this.data; 21 | /** @type {Object} Additional headers to send with the request. */ 22 | this.headers; 23 | // /** @type {number} If rate limited, how long in seconds to allow the request to sit in the queue before canceling. */ 24 | // this.timeout; 25 | 26 | /** @type {Object} If queued, will be the response when this request is sent. */ 27 | this.response; 28 | /** @type {number} If queued when using the rate limit rpc service, a timestamp of when the request will first be available to try again. */ 29 | this.waitUntil; 30 | 31 | Object.assign(this, options); 32 | } 33 | 34 | /** @type {Object} Data relevant to sending this request via axios. */ 35 | get sendData() { 36 | return { 37 | method: this.method, 38 | url: this.url, 39 | data: this.data, 40 | headers: this.headers, 41 | validateStatus: null, // Tells axios not to throw errors when a non-200 response codes are encountered. 42 | }; 43 | } 44 | 45 | /** 46 | * Assigns a stricter value to `waitUntil`. 47 | * Strictness is defined by the value that decreases the chance of getting rate limited. 48 | * @param {number} waitUntil A timestamp of when the request will first be available to try again when queued due to rate limits. 49 | */ 50 | assignIfStricterWait(waitUntil) { 51 | if (this.waitUntil === undefined || this.waitUntil < waitUntil) { 52 | this.waitUntil = waitUntil; 53 | } 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/clients/Api/structures/RequestQueue.js: -------------------------------------------------------------------------------- 1 | /* 2 | This structure doesn't have to be as complex as it is and was partly a fun experiment in doing some manual array allocation. 3 | While in some cases, having a fixed-size array is beneficial by removing the overhead of constantly allocation/freeing memory 4 | for every single item, there's no reason to believe that there would be any tangible benefits in maintaining such an array here. 5 | */ 6 | 7 | /* 8 | TODO(lando): should prevent new requests from cutting in line. 9 | A possible solution could be to iterate over the queue and check for a match rateLimitKey. 10 | this solution may be preferred over tracking state of the rate limit key, it's simpler. 11 | */ 12 | 13 | // TODO(lando): Do some logging on this in prod to make sure it doesn't memory leak. 14 | 15 | 'use strict'; 16 | 17 | /** @typedef {import("./Request")} Request */ 18 | 19 | /** A queue for rate limited requests waiting to be sent. */ 20 | module.exports = class RequestQueue { 21 | /** 22 | * Creates a new requests queue for rate limits requests. 23 | * 24 | * @param {RateLimitCache} rateLimitCache The cache used to check the state of rate limits. 25 | * @param {Api} apiClient Api client through which to emit events. 26 | */ 27 | constructor(rateLimitCache, apiClient) { 28 | /** @type {RateLimitCache} The cache used to check the state of rate limits. */ 29 | this.rateLimitCache = rateLimitCache; 30 | /** @type {boolean} Whether or not the `process()` method is already executing. */ 31 | this.processing = false; 32 | /** @type {import("./Request")[]} The queue. */ 33 | this.queue = []; 34 | /** @type {number} The internal value of the length of the queue. */ 35 | this._length = 0; 36 | /** @type {Api} Api client through which to emit events. */ 37 | this.apiClient = apiClient; 38 | } 39 | 40 | /** @type {number} The length of the queue. */ 41 | get length() { 42 | return this._length; 43 | } 44 | 45 | /** 46 | * Adds any number of requests to the queue. 47 | * @param {...Request} items Request objects being queued. 48 | */ 49 | push(...items) { 50 | items.forEach((i) => { 51 | this.queue[++this._length - 1] = i; 52 | }); 53 | } 54 | 55 | /** 56 | * Removes requests from the queue. 57 | * 58 | * @param {number[]} indices Indices of the requests to be removed. 59 | */ 60 | spliceMany(indices) { 61 | if (indices.length === 0) return; 62 | 63 | this._length = 0; 64 | 65 | // Re-assign values to array indexes, shifting up all remaining requests when an index should be skipped. 66 | for (let idx = 0; idx < this.queue.length; ++idx) { 67 | // undefined = past end of array; null = past end of requests in array (rest are null) 68 | if (this.queue[idx] === undefined || this.queue[idx] === null) break; 69 | if (!indices.includes(idx)) { 70 | this.queue[this._length] = this.queue[idx]; 71 | ++this._length; 72 | } 73 | } 74 | 75 | // Assigns `null` to the remaining indices. 76 | for (let idx = this._length; idx < this.queue.length; ++idx) { 77 | if (this.queue[idx] === undefined || this.queue[idx] === null) break; 78 | 79 | this.queue[idx] = null; 80 | } 81 | } 82 | 83 | /** Iterates over the queue, sending any requests that are no longer rate limited. */ 84 | async process() { 85 | if (this.length === 0 || this.processing) return; 86 | 87 | try { 88 | this.processing = true; 89 | 90 | /* Below two lines are the quintessential premature micro-optimization. */ 91 | const removedIndices = []; 92 | 93 | for (let queueIdx = 0; queueIdx < this.length; ++queueIdx) { 94 | await this.processIteration(queueIdx, removedIndices); 95 | } 96 | 97 | this.spliceMany(removedIndices); 98 | } finally { 99 | this.processing = false; 100 | } 101 | } 102 | 103 | /** 104 | * Handles an item on the queue. 105 | * 106 | * @param {number} queueIdx Index of the current place in the queue. 107 | * @param {number[]} processedIndices The indices of requests to remove from th queue. 108 | */ 109 | async processIteration(queueIdx, removedIndices) { 110 | const request = this.queue[queueIdx]; 111 | 112 | if ( 113 | request.waitUntil !== undefined 114 | && request.waitUntil > new Date().getTime() 115 | ) { 116 | return; 117 | } 118 | 119 | try { 120 | // if (request.timeout <= new Date().getTime()) { 121 | // removedIndices.push(queueIdx); 122 | // } else 123 | if (await this.apiClient.returnOkToMakeRequest(request, true)) { 124 | request.response = this.apiClient.sendQueuedRequest(request); 125 | 126 | removedIndices.push(queueIdx); 127 | } 128 | } catch (err) { 129 | if (err.code === 14) { 130 | removedIndices.push(queueIdx); 131 | } 132 | } 133 | } 134 | }; 135 | -------------------------------------------------------------------------------- /src/clients/Api/structures/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | BaseRequest: require('./BaseRequest'), 5 | RateLimit: require('./RateLimit'), 6 | RateLimitCache: require('./RateLimitCache'), 7 | RateLimitHeaders: require('./RateLimitHeaders'), 8 | RateLimitMap: require('./RateLimitMap'), 9 | Request: require('./Request'), 10 | RequestQueue: require('./RequestQueue'), 11 | }; 12 | -------------------------------------------------------------------------------- /src/clients/Api/structures/tests/RateLimit.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const sinon = require('sinon'); 5 | const RateLimit = require('../RateLimit'); 6 | 7 | describe('RateLimit', () => { 8 | let rateLimit; 9 | 10 | beforeEach(() => { 11 | rateLimit = new RateLimit({}); 12 | }); 13 | 14 | describe('get isRateLimited', () => { 15 | let stub_hasRemainingUses; 16 | let stub_rateLimitHasExpired; 17 | let stub_reset; 18 | 19 | beforeEach(() => { 20 | stub_hasRemainingUses = sinon.stub(rateLimit, 'hasRemainingUses'); 21 | stub_rateLimitHasExpired = sinon.stub(rateLimit, 'rateLimitHasExpired'); 22 | stub_reset = sinon.stub(rateLimit, 'reset'); 23 | }); 24 | 25 | it('hasRemainingUses=true => Returns false', () => { 26 | stub_hasRemainingUses.value(true); 27 | 28 | const got = rateLimit.isRateLimited; 29 | 30 | assert.strictEqual(got, false); 31 | }); 32 | it('rateLimitHasExpired=true => Executes reset and returns false', () => { 33 | stub_hasRemainingUses.value(false); 34 | stub_rateLimitHasExpired.value(true); 35 | 36 | const got = rateLimit.isRateLimited; 37 | 38 | sinon.assert.calledOnce(stub_reset); 39 | assert.strictEqual(got, false); 40 | }); 41 | it('All values false => Returns true', () => { 42 | stub_hasRemainingUses.value(false); 43 | stub_rateLimitHasExpired.value(false); 44 | 45 | const got = rateLimit.isRateLimited; 46 | 47 | assert.strictEqual(got, true); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/clients/Api/structures/tests/RequestQueue.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const sinon = require('sinon'); 5 | const RequestQueue = require('../RequestQueue'); 6 | 7 | describe('RequestQueue', async () => { 8 | let q; 9 | let clock; 10 | 11 | beforeEach(() => { 12 | q = new RequestQueue(); 13 | clock = sinon.useFakeTimers(); 14 | }); 15 | 16 | afterEach(() => { 17 | clock.restore(); 18 | }); 19 | 20 | describe('initial state', () => { 21 | it('none => length is 0', () => { 22 | assert.deepStrictEqual(q.queue, []); 23 | assert.strictEqual(q.length, 0); 24 | }); 25 | }); 26 | 27 | describe('push', () => { 28 | it('1 parameter => item in queue and length is 1', () => { 29 | q.push(1); 30 | assert.deepStrictEqual(q.queue, [1]); 31 | assert.strictEqual(q.length, 1); 32 | }); 33 | it('3 parameters => items in queue and length is 3', () => { 34 | q.push(1, '2', {}); 35 | assert.deepStrictEqual(q.queue, [1, '2', {}]); 36 | assert.strictEqual(q.length, 3); 37 | }); 38 | it('4 parameters with array => items in queue and length is 4', () => { 39 | q.push(1, '2', {}, []); 40 | q.spliceMany([]); 41 | assert.deepStrictEqual(q.queue, [1, '2', {}, []]); 42 | assert.strictEqual(q.length, 4); 43 | }); 44 | }); 45 | 46 | describe('spliceMany', () => { 47 | it('first index => queue with second index and null and length of 1', () => { 48 | q.push(1, '2'); 49 | q.spliceMany([0]); 50 | assert.deepStrictEqual(q.queue, ['2', null]); 51 | assert.strictEqual(q.length, 1); 52 | }); 53 | it('index out of bounds => queue unchanged', () => { 54 | q.push(1, '2'); 55 | q.spliceMany([2]); 56 | assert.deepStrictEqual(q.queue, [1, '2']); 57 | assert.strictEqual(q.length, 2); 58 | }); 59 | it('mulitple indices => queue only nulls and length 0', () => { 60 | q.push(1, '2'); 61 | assert.strictEqual(q.length, 2); 62 | 63 | q.spliceMany([0, 1]); 64 | assert.deepStrictEqual(q.queue, [null, null]); 65 | assert.strictEqual(q.length, 0); 66 | }); 67 | it('push with null and spliceMany => length 1', () => { 68 | q.push(1, null); 69 | assert.strictEqual(q.length, 2); 70 | 71 | q.spliceMany([1]); 72 | assert.deepStrictEqual(q.queue, [1, null]); 73 | assert.strictEqual(q.length, 1); 74 | }); 75 | }); 76 | 77 | describe('process', () => {}); 78 | }); 79 | -------------------------------------------------------------------------------- /src/clients/Gateway/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "main": "./Gateway.js" 4 | } -------------------------------------------------------------------------------- /src/clients/Gateway/structures/Identity.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** A container of information for identifying with the gateway. https://discordapp.com/developers/docs/topics/gateway#identify-identify-structure */ 4 | module.exports = class Identity { 5 | /** 6 | * Creates a new Identity object for use with the gateway. 7 | * 8 | * @param {string} token Bot token. 9 | * @param {void|Object} [identity] Properties to add to this identity. 10 | */ 11 | constructor(token, identity = {}) { 12 | /** @type {Object} Information about platform the client is connecting from. */ 13 | this.properties = { 14 | os: process.platform, 15 | browser: 'Paracord', 16 | device: 'Paracord', 17 | }; 18 | 19 | /** @type {Object} Presence of the bot when identifying. */ 20 | this.presence = { 21 | status: 'online', 22 | afk: false, 23 | }; 24 | 25 | Object.assign(this, identity); 26 | 27 | this.shard = [Number(this.shard[0]), Number(this.shard[1])]; 28 | 29 | /** @type {string} Bot token. */ 30 | this.token = token; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/clients/Paracord/Paracord.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { EventEmitter } = require('events'); 4 | const Guild = require('./structures/Guild'); 5 | const Api = require('../Api'); 6 | const Gateway = require('../Gateway'); 7 | const Utils = require('../../utils'); 8 | const { 9 | SECOND_IN_MILLISECONDS, 10 | MINUTE_IN_MILLISECONDS, 11 | LOG_LEVELS, 12 | LOG_SOURCES, 13 | PARACORD_SWEEP_INTERVAL, 14 | } = require('../../utils/constants'); 15 | 16 | const { PARACORD_SHARD_IDS, PARACORD_SHARD_COUNT } = process.env; 17 | 18 | /* "Start up" refers to logging in to the gateway and waiting for all the guilds to be returned. By default, events will be suppressed during start up. */ 19 | 20 | /** 21 | * A client that provides caching and limited helper functions. Integrates the Api and Gateway clients into a seamless experience. 22 | * 23 | * @extends EventEmitter 24 | */ 25 | module.exports = class Paracord extends EventEmitter { 26 | /** 27 | * Creates a new Paracord client. 28 | * 29 | * @param {string} token Discord bot token. Will be coerced into a bot token. 30 | * @param {ParacordOptions} options Settings for this Paracord instance. 31 | */ 32 | constructor(token, options = {}) { 33 | super(); 34 | /** @type {string} Discord bot token. */ 35 | this.token = token; 36 | /** @type {boolean} Whether or not the `init()` function has already been called. */ 37 | this.initialized = false; 38 | 39 | /* Internal clients. */ 40 | /** @type {Api} Client through which to make REST api calls to Discord. */ 41 | this.api; 42 | /** @type {Gateway[]]} Client through which to interact with Discord's gateway. */ 43 | this.gateways; 44 | /** @type {Gateway[]]} Gateways queue to log in. */ 45 | this.gatewayLoginQueue; 46 | /** @type {GatewayLockServerOptions} Identify lock service Options passed to the gateway shards. */ 47 | this.gatewayLockServiceOptions; 48 | 49 | /* State that tracks the start up process. */ 50 | /** @type {number} Timestamp of the last gateway identify. */ 51 | this.safeGatewayIdentifyTimestamp; 52 | /** @type {number} Gateways left to login on start up before emitting `PARACORD_STARTUP_COMPLETE` event. */ 53 | this.gatewayWaitCount; 54 | /** @type {void|Gateway} Shard currently in the initial phases of the gateway connection in progress. */ 55 | this.startingGateway; 56 | /** @type {number} Guilds left to ingest on start up before emitting `PARACORD_STARTUP_COMPLETE` event. */ 57 | this.guildWaitCount; 58 | /** @type {Object} User details given by Discord in the "Ready" event form the gateway. https://discordapp.com/developers/docs/topics/gateway#ready-ready-event-fields */ 59 | this.user; 60 | 61 | /* Client caches. */ 62 | /** @type {Map} Guild cache. */ 63 | this.guilds; 64 | /** @type {Map} User cache. */ 65 | this.users; 66 | /** @type {Map} Presence cache. */ 67 | this.presences; 68 | 69 | /** @type {NodeJS.Timer} Interval that coordinates gateway logins. */ 70 | this.processGatewayQueueInterval; 71 | /** @type {NodeJS.Timer} Interval that removes objects from the presence and user caches. */ 72 | this.sweepCachesInterval; 73 | /** @type {NodeJS.Timer} Interval that removes object from the redundant presence update cache. */ 74 | this.sweepRecentPresenceUpdatesInterval; 75 | /** @type {Map} A short-tern cache for presence updates used to avoid processing the same event multiple times. */ 76 | this.veryRecentlyUpdatedPresences; 77 | /** @type {Map} A short-tern cache for user updates used to avoid processing the same event multiple times. */ 78 | this.veryRecentlyUpdatedUsers; 79 | 80 | /* User-defined event handling behavior. */ 81 | /** @type {Object} Key:Value mapping DISCORD_EVENT to user's preferred emitted name for use when connecting to the gateway. */ 82 | this.events; 83 | /** @type {boolean} During startup, if events should be emitted before `PARACORD_STARTUP_COMPLETE` is emitted. `GUILD_CREATE` events will never be emitted during start up. */ 84 | this.allowEventsDuringStartup; 85 | 86 | this.constructorDefaults(token, options); 87 | } 88 | 89 | /* 90 | ******************************** 91 | ********* CONSTRUCTOR ********** 92 | ******************************** 93 | */ 94 | 95 | /** 96 | * Assigns default values to this Paracord instance based on the options. 97 | * @private 98 | * 99 | * @param {string} token Discord token. Will be coerced into a bot token. 100 | * @param {ParacordOptions} options Optional parameters for this handler. 101 | */ 102 | constructorDefaults(token, options) { 103 | Paracord.validateParams(token); 104 | 105 | const defaults = { 106 | guilds: new Map(), 107 | users: new Map(), 108 | presences: new Map(), 109 | veryRecentlyUpdatedPresences: new Map(), 110 | veryRecentlyUpdatedUsers: new Map(), 111 | safeGatewayIdentifyTimestamp: 0, 112 | gateways: [], 113 | gatewayLoginQueue: [], 114 | gatewayWaitCount: 0, 115 | startingGateway: null, 116 | guildWaitCount: 0, 117 | allowEventsDuringStartup: false, 118 | }; 119 | 120 | Object.assign(this, { ...options, ...defaults }); 121 | 122 | if (options.autoInit === undefined || options.autoInit) { 123 | this.init(); 124 | } 125 | this.bindTimerFunction(); 126 | this.bindEventFunctions(); 127 | } 128 | 129 | /** 130 | * Throws errors and warns if the parameters passed to the constructor aren't sufficient. 131 | * @private 132 | */ 133 | static validateParams(token) { 134 | if (token === undefined) { 135 | throw Error("client requires a 'token'"); 136 | } 137 | } 138 | 139 | /** 140 | * Binds `this` to the event functions defined in a separate file. 141 | * @private 142 | */ 143 | bindEventFunctions() { 144 | Utils.bindFunctionsFromFile(this, require('./eventFuncs')); 145 | } 146 | 147 | /** 148 | * Binds `this` to functions that are used in timeouts and intervals. 149 | * @private 150 | */ 151 | bindTimerFunction() { 152 | this.sweepCaches = this.sweepCaches.bind(this); 153 | this.sweepOldUpdates = this.sweepOldUpdates.bind(this); 154 | this.processGatewayQueue = this.processGatewayQueue.bind(this); 155 | } 156 | 157 | /* 158 | ******************************** 159 | *********** INTERNAL *********** 160 | ******************************** 161 | */ 162 | 163 | /** 164 | * Processes a gateway event. 165 | * 166 | * @param {string} eventType The type of the event from the gateway. https://discordapp.com/developers/docs/topics/gateway#commands-and-events-gateway-events (Events tend to be emitted in all caps and underlines in place of spaces.) 167 | * @param {Object} data From Discord. 168 | * @param {number} shard Shard of the gateway that emitted this event. 169 | */ 170 | eventHandler(eventType, data, shard) { 171 | /** @type {Function|void} Method defined in ParacordEvents.js */ 172 | let emit = data; 173 | 174 | const paracordEvent = this[eventType]; 175 | if (paracordEvent !== undefined) { 176 | emit = paracordEvent(data, shard); 177 | } 178 | 179 | if (this.startingGateway !== null && this.startingGateway.shard === shard) { 180 | if (eventType === 'GUILD_CREATE') { 181 | this.checkIfDoneStarting(); 182 | return undefined; 183 | } 184 | return this.allowEventsDuringStartup ? data : undefined; 185 | } 186 | 187 | return emit; 188 | } 189 | 190 | /** 191 | * Simple alias for logging events emitted by this client. 192 | * @private 193 | * 194 | * @param {string} level Key of the logging level of this message. 195 | * @param {string} message Content of the log. 196 | * @param {*} [data] Data pertinent to the event. 197 | */ 198 | log(level, message, data) { 199 | this.emit('DEBUG', { 200 | source: LOG_SOURCES.API, 201 | level: LOG_LEVELS[level], 202 | message, 203 | data, 204 | }); 205 | } 206 | 207 | /** 208 | * Proxy emitter. Renames type with a key in `this.events`. 209 | * 210 | * @param {string} type Name of the event. 211 | * @param {...any} args Any arguments to send with the emitted event. 212 | */ 213 | emit(type, ...args) { 214 | if (this.events === undefined || this.events[type] === undefined) { 215 | super.emit(type, ...args); 216 | } else { 217 | super.emit(this.events[type], ...args); 218 | } 219 | } 220 | 221 | /* 222 | ******************************** 223 | ************ LOGIN ************* 224 | ******************************** 225 | */ 226 | 227 | /** 228 | * Connects to Discord's gateway and begins receiving and emitting events. 229 | * 230 | * @param {ParacordLoginOptions} [options] Options used when logging in. 231 | */ 232 | async login(options = {}) { 233 | if (!this.initialized) { 234 | this.init(); 235 | } 236 | 237 | if (PARACORD_SHARD_IDS !== undefined) { 238 | options.shards = PARACORD_SHARD_IDS.split(','); 239 | options.shardCount = PARACORD_SHARD_COUNT; 240 | const message = `Injecting shard settings from shard launcher. Shard Ids: ${options.shards}. Shard count: ${options.shardCount}`; 241 | this.log('INFO', message); 242 | } 243 | 244 | this.startGatewayLoginInterval(); 245 | await this.enqueueGateways(options); 246 | 247 | this.allowEventsDuringStartup = options.allowEventsDuringStartup || false; 248 | 249 | this.startSweepIntervals(); 250 | } 251 | 252 | /** 253 | * Begins the interval that kicks off gateway logins from the queue. 254 | * @private 255 | */ 256 | startGatewayLoginInterval() { 257 | this.processGatewayQueueInterval = setInterval( 258 | this.processGatewayQueue, SECOND_IN_MILLISECONDS, 259 | ); 260 | } 261 | 262 | /** 263 | * Takes a gateway off of the queue and logs it in. 264 | * @private 265 | */ 266 | async processGatewayQueue() { 267 | if (this.gatewayLoginQueue.length) { 268 | if (this.gatewayLoginQueue[0].resumable) { 269 | const gateway = this.gatewayLoginQueue.shift(); 270 | await gateway.login(); 271 | } else if ( 272 | this.startingGateway === null 273 | && new Date().getTime() > this.safeGatewayIdentifyTimestamp 274 | ) { 275 | const gateway = this.gatewayLoginQueue.shift(); 276 | this.safeGatewayIdentifyTimestamp = 10 * SECOND_IN_MILLISECONDS; // arbitrary buffer 277 | 278 | /* eslint-disable-next-line prefer-destructuring */ 279 | this.startingGateway = gateway; 280 | try { 281 | await gateway.login(); 282 | } catch (err) { 283 | this.log('FATAL', err.message, gateway); 284 | this.startingGateway = null; 285 | } 286 | } 287 | } 288 | } 289 | 290 | /** 291 | * Decides shards to spawn and pushes a gateway onto the queue for each one. 292 | * @private 293 | * 294 | * @param {ParacordLoginOptions} [options] Options used when logging in. 295 | */ 296 | async enqueueGateways(options) { 297 | let { shards, shardCount, identity } = options; 298 | if (shards && shardCount) { 299 | shards.forEach((s) => { 300 | if (s + 1 > shardCount) { 301 | throw Error(`shard id ${s} exceeds max shard id of ${shardCount - 1}`); 302 | } 303 | }); 304 | } 305 | 306 | 307 | if (identity !== undefined && Object.prototype.hasOwnProperty.call(identity, 'shard')) { 308 | const identityCopy = Utils.clone(identity); // mirror above behavior 309 | this.addNewGateway(identityCopy); 310 | } else { 311 | ({ shards, shardCount } = await this.computeShards(shards, shardCount)); 312 | 313 | shards.forEach((shard) => { 314 | const identityCopy = Utils.clone(identity || {}); 315 | identityCopy.shard = [shard, shardCount]; 316 | this.addNewGateway(identityCopy); 317 | }); 318 | } 319 | } 320 | 321 | /** 322 | * Creates gateway and pushes it into cache and login queue. 323 | * @private 324 | * 325 | * @param {Object} identity An object containing information for identifying with the gateway. https://discordapp.com/developers/docs/topics/gateway#identify-identify-structure 326 | */ 327 | addNewGateway(identity) { 328 | const gatewayOptions = { identity, api: this.api, emitter: this }; 329 | const gateway = this.setUpGateway(this.token, gatewayOptions); 330 | ++this.gatewayWaitCount; 331 | this.gateways.push(gateway); 332 | this.gatewayLoginQueue.push(gateway); 333 | } 334 | 335 | /** Sets up the internal handlers for this client. */ 336 | init() { 337 | if (this.initialized) { 338 | throw Error('Client has already been initialized.'); 339 | } 340 | this.api = this.setUpApi(this.token, this.apiOptions); 341 | this.selfAssignHandlerFunctions(); 342 | this.initialized = true; 343 | } 344 | 345 | /** 346 | * Determines which shards will be spawned. 347 | * @private 348 | * 349 | * @param {number[]|void} shards Shard Ids to spawn. 350 | * @param {number|void} shardCount Total number of shards 351 | */ 352 | async computeShards(shards, shardCount) { 353 | if (shards !== undefined && shardCount === undefined) { 354 | throw Error('shards defined with no shardCount.'); 355 | } 356 | 357 | if (shardCount === undefined) { 358 | const { status, data: { shards: recommendedShards } } = await this.api.request( 359 | 'get', 360 | 'gateway/bot', 361 | ); 362 | if (status === 200) { 363 | shardCount = recommendedShards; 364 | } 365 | } 366 | 367 | if (shards === undefined) { 368 | shards = []; 369 | for (let i = 0; i < shardCount; ++i) { 370 | shards.push(i); 371 | } 372 | } 373 | 374 | return { shards, shardCount }; 375 | } 376 | 377 | /** 378 | * Begins the intervals that prune caches. 379 | * @private 380 | */ 381 | startSweepIntervals() { 382 | this.sweepCachesInterval = setInterval( 383 | this.sweepCaches, 384 | 60 * MINUTE_IN_MILLISECONDS, 385 | ); 386 | 387 | this.sweepOldUpdatesInterval = setInterval( 388 | this.sweepOldUpdates, 389 | PARACORD_SWEEP_INTERVAL, 390 | ); 391 | } 392 | 393 | /* 394 | ******************************** 395 | ************ SETUP ************* 396 | ******************************** 397 | */ 398 | 399 | /** 400 | * Creates the handler used when handling REST calls to Discord. 401 | * @private 402 | * 403 | * @param {string} token Discord token. Will be coerced to bot token. 404 | * @param {ApiOptions} options 405 | */ 406 | setUpApi(token, options) { 407 | const api = new Api(token, { ...options, emitter: this }); 408 | if (api.rpcRequestService === undefined) { 409 | api.startQueue(); 410 | } 411 | 412 | return api; 413 | } 414 | 415 | /** 416 | * Creates the handler used when connecting to Discord's gateway. 417 | * @private 418 | * 419 | * @param {string} token Discord token. Will be coerced to bot token. 420 | * @param {GatewayOptions} options 421 | */ 422 | setUpGateway(token, options) { 423 | const gateway = new Gateway(token, { 424 | ...options, 425 | emitter: this, 426 | api: this.api, 427 | }); 428 | 429 | if (this.gatewayLockServiceOptions) { 430 | const { mainServerOptions, serverOptions } = this.gatewayLockServiceOptions; 431 | gateway.addIdentifyLockServices(mainServerOptions, ...serverOptions); 432 | } 433 | 434 | return gateway; 435 | } 436 | 437 | /** 438 | * Assigns some public functions from handlers to this client for easier access. 439 | * @private 440 | */ 441 | selfAssignHandlerFunctions() { 442 | this.request = this.api.request.bind(this.api); 443 | this.addRateLimitService = this.api.addRateLimitService.bind(this.api); 444 | this.addRequestService = this.api.addRequestService.bind(this.api); 445 | } 446 | 447 | /** 448 | * Stores options that will be passed to each gateway shard when adding the service that will acquire a lock from a server(s) before identifying. 449 | * 450 | * @param {void|ServerOptions} mainServerOptions Options for connecting this service to the identifylock server. Will not be released except by time out. Best used for global minimum wait time. Pass `null` to ignore. 451 | * @param {ServerOptions} [serverOptions] Options for connecting this service to the identifylock server. Will be acquired and released in order. 452 | */ 453 | addIdentifyLockServices(mainServerOptions, ...serverOptions) { 454 | this.gatewayLockServiceOptions = { 455 | mainServerOptions, 456 | serverOptions, 457 | }; 458 | } 459 | 460 | /* 461 | ******************************** 462 | ********** START UP ************ 463 | ******************************** 464 | */ 465 | 466 | /** 467 | * Prepares the client for caching guilds on start up. 468 | * @private 469 | * 470 | * @param {Object} data Number of unavailable guilds received from Discord. 471 | */ 472 | handleReady(data) { 473 | const { user, guilds } = data; 474 | 475 | guilds.forEach((g) => this.guilds.set(g.id, new Guild(g, this))); 476 | 477 | user.tag = Utils.constructUserTag(user); 478 | this.user = user; 479 | this.log('INFO', `Logged in as ${user.tag}.`); 480 | 481 | 482 | const message = `Ready event received. Waiting on ${guilds.length} guilds.`; 483 | if (guilds.length === 0) { 484 | this.checkIfDoneStarting(true); 485 | } else { 486 | this.guildWaitCount = guilds.length; 487 | } 488 | 489 | this.log('INFO', message); 490 | } 491 | 492 | /** 493 | * Runs with every GUILD_CREATE on initial start up. Decrements counter and emits `PARACORD_STARTUP_COMPLETE` when 0. 494 | * @private 495 | * 496 | * @param {boolean} emptyShard Whether or not the shard started with no guilds. 497 | */ 498 | checkIfDoneStarting(emptyShard = false) { 499 | if (!emptyShard) { 500 | --this.guildWaitCount; 501 | } 502 | 503 | let message = `Shard ${this.startingGateway.shard} - ${this.guildWaitCount} guilds left in start up.`; 504 | if (this.guildWaitCount === 0 && this.startingGateway !== null) { 505 | message = `Shard ${this.startingGateway.shard} - received all start up guilds.`; 506 | this.startingGateway.releaseIdentifyLocks(); 507 | this.startingGateway = null; 508 | --this.gatewayWaitCount; 509 | 510 | if (this.gatewayWaitCount === 0) { 511 | this.completeStartup(); 512 | } 513 | } else if (this.guildWaitCount < 0) { 514 | message = `Shard ${this.startingGateway.shard} - guildWaitCount is less than 0. This should not happen. guildWaitCount value: ${this.guildWaitCount}`; 515 | this.log('WARNING', message); 516 | return; 517 | } 518 | 519 | this.log('INFO', message); 520 | } 521 | 522 | /** 523 | * Cleans up Paracord's start up process and emits `PARACORD_STARTUP_COMPLETE`. 524 | * @private 525 | * 526 | * @param {string} [reason] Reason for the time out. 527 | */ 528 | completeStartup(reason) { 529 | this.gatewayLoginQueue.shift(); 530 | this.startingGateway = null; 531 | 532 | // this.clearStartupTimers(); 533 | 534 | let message = 'Paracord start up complete.'; 535 | if (reason !== undefined) { 536 | message += ` ${reason}`; 537 | } 538 | 539 | this.log('INFO', message); 540 | this.emit('PARACORD_STARTUP_COMPLETE'); 541 | } 542 | 543 | /* 544 | ******************************** 545 | *********** CACHING ************ 546 | ******************************** 547 | */ 548 | 549 | /** 550 | * Inserts/updates properties of a guild. 551 | * @private 552 | * 553 | * @param {Object} data From Discord - https://discordapp.com/developers/docs/resources/guild#guild-object 554 | * @param {Paracord} client 555 | * @param {Function} Guild Ignore. For dependency injection. 556 | */ 557 | upsertGuild(data, GuildConstructor = Guild) { 558 | const cachedGuild = this.guilds.get(data.id); 559 | if (cachedGuild !== undefined) { 560 | return cachedGuild.constructGuildFromData(data, this); 561 | } 562 | const guild = new GuildConstructor(data, this); 563 | this.guilds.set(data.id, guild); 564 | return guild; 565 | } 566 | 567 | /** 568 | * Inserts/updates user in this client's cache. 569 | * @private 570 | * 571 | * @param {Object} user From Discord - https://discordapp.com/developers/docs/resources/user#user-object-user-structure 572 | * @param {Paracord} client 573 | */ 574 | upsertUser(user) { 575 | let cachedUser = this.users.get(user.id) || {}; 576 | cachedUser.tag = Utils.constructUserTag(user); 577 | 578 | cachedUser = Object.assign(cachedUser, user); 579 | 580 | this.users.set(cachedUser.id, cachedUser); 581 | 582 | Utils.assignCreatedOn(cachedUser); 583 | this.circularAssignCachedPresence(cachedUser); 584 | 585 | return cachedUser; 586 | } 587 | 588 | /** 589 | * Adjusts the client's presence cache, allowing ignoring events that may be redundant. 590 | * @private 591 | * 592 | * @param {Object} presence From Discord - https://discordapp.com/developers/docs/topics/gateway#presence-update 593 | */ 594 | updatePresences(presence) { 595 | let cachedPresence; 596 | 597 | if (!this.veryRecentlyUpdatedPresences.has(presence.user.id)) { 598 | if (presence.status !== 'offline') { 599 | cachedPresence = this.upsertPresence(presence); 600 | } else { 601 | this.deletePresence(presence.user.id); 602 | } 603 | } else { 604 | cachedPresence = this.presences.get(presence.user.id); 605 | } 606 | 607 | this.veryRecentlyUpdatedPresences.set( 608 | presence.user.id, 609 | new Date().getTime() + 500, 610 | ); 611 | 612 | return cachedPresence; 613 | } 614 | 615 | /** 616 | * Inserts/updates presence in this client's cache. 617 | * @private 618 | * 619 | * @param {Object} presence From Discord - https://discordapp.com/developers/docs/topics/gateway#presence-update 620 | */ 621 | upsertPresence(presence) { 622 | const cachedPresence = this.presences.get(presence.user.id); 623 | if (cachedPresence !== undefined) { 624 | presence = Object.assign(cachedPresence, presence); 625 | } else { 626 | this.presences.set(presence.user.id, presence); 627 | } 628 | 629 | this.circularAssignCachedUser(presence); 630 | 631 | return presence; 632 | } 633 | 634 | /** 635 | * Ensures that a user is assigned its presence from the cache and vice versa. 636 | * @private 637 | * 638 | * @param {Object} user From Discord - https://discordapp.com/developers/docs/resources/user#user-object 639 | */ 640 | circularAssignCachedPresence(user) { 641 | const cachedPresence = this.presences.get(user.id); 642 | if (cachedPresence !== undefined) { 643 | user.presence = cachedPresence; 644 | user.presence.user = user; 645 | } 646 | } 647 | 648 | /** 649 | * Ensures that a presence is assigned its user from the cache and vice versa. 650 | * @private 651 | * 652 | * @param {Object} presence From Discord - https://discordapp.com/developers/docs/topics/gateway#presence-update 653 | */ 654 | circularAssignCachedUser(presence) { 655 | const cachedUser = this.users.get(presence.user.id); 656 | if (cachedUser !== undefined) { 657 | presence.user = cachedUser; 658 | presence.user.presence = presence; 659 | } 660 | } 661 | 662 | /** 663 | * Removes presence from cache. 664 | * @private 665 | * 666 | * @param {string} userId Id of the presence's user. 667 | */ 668 | deletePresence(userId) { 669 | this.presences.delete(userId); 670 | const user = this.users.get(userId); 671 | if (user !== undefined) { 672 | user.presence = undefined; 673 | } 674 | } 675 | 676 | /** 677 | * Processes presences (e.g. from PRESENCE_UPDATE, GUILD_MEMBERS_CHUNK, etc.) 678 | * @private 679 | * 680 | * @param {Guild} guild Paracord guild. 681 | * @param {Object} presence From Discord. More information on a particular payload can be found in the official docs. https://discordapp.com/developers/docs/topics/gateway#presence-update 682 | */ 683 | handlePresence(guild, presence) { 684 | const cachedPresence = this.updatePresences(presence); 685 | 686 | if (cachedPresence !== undefined) { 687 | guild.setPresence(cachedPresence); 688 | } else { 689 | guild.deletePresence(presence.user.id); 690 | } 691 | } 692 | 693 | /** 694 | * Processes a member object (e.g. from MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.) 695 | * @private 696 | * 697 | * @param {Guild} guild Paracord guild. 698 | * @param {Object} member From Discord. More information on a particular payload can be found in the official docs. https://discordapp.com/developers/docs/resources/guild#guild-member-object 699 | */ 700 | cacheMemberFromEvent(guild, member) { 701 | if (member !== undefined) { 702 | const cachedMember = guild.members.get(member.user.id); 703 | if (cachedMember === undefined) { 704 | return guild.upsertMember(member, this); 705 | } 706 | return cachedMember; 707 | } 708 | 709 | return member; 710 | } 711 | 712 | /** 713 | * Removes from presence and user caches users who are no longer in a cached guild. 714 | * @private 715 | */ 716 | sweepCaches() { 717 | const deleteIds = new Map([...this.presences, ...this.users]); 718 | 719 | Paracord.trimMembersFromDeleteList(deleteIds, this.guilds.values()); 720 | 721 | let sweptCount = 0; 722 | for (const id of deleteIds.keys()) { 723 | this.clearUserFromCaches(id); 724 | ++sweptCount; 725 | } 726 | 727 | this.log('INFO', `Swept ${sweptCount} users from caches.`); 728 | } 729 | 730 | /** 731 | * Remove users referenced in a guild's members or presences from the delete list. 732 | * @private 733 | * 734 | * @param {Map} deleteIds Unique set of user ids in a map. 735 | * @param {IterableIterator} guilds An iterable of guilds. 736 | * */ 737 | static trimMembersFromDeleteList(deleteIds, guilds) { 738 | for (const { members, presences } of guilds) { 739 | for (const id of new Map([...members, ...presences]).keys()) { 740 | deleteIds.delete(id); 741 | } 742 | } 743 | } 744 | 745 | /** 746 | * Delete the user and its presence from this client's cache. 747 | * @private 748 | * 749 | * @param {string} id User id. 750 | */ 751 | clearUserFromCaches(id) { 752 | this.presences.delete(id); 753 | this.users.delete(id); 754 | } 755 | 756 | /** 757 | * Removes outdated states from the redundancy caches. 758 | * @private 759 | */ 760 | sweepOldUpdates() { 761 | const now = new Date().getTime(); 762 | 763 | Paracord.sweepOldEntries(now, this.veryRecentlyUpdatedPresences); 764 | Paracord.sweepOldEntries(now, this.veryRecentlyUpdatedUsers); 765 | } 766 | 767 | /** Remove entries from a map whose timestamp is older than now. */ 768 | static sweepOldEntries(now, map) { 769 | for (const [id, ts] of map.entries()) { 770 | if (ts < now) { 771 | map.delete(id); 772 | } 773 | } 774 | } 775 | 776 | /* 777 | ******************************** 778 | ******* PUBLIC HELPERS ********* 779 | ******************************** 780 | */ 781 | 782 | /** 783 | * Short-hand for sending a message to Discord. 784 | * 785 | * @param {string} channelId Discord snowflake of the channel to send the message. 786 | * @param {string|Object} message When a string is passed for `message`, that string will populate the `content` field. https://discordapp.com/developers/docs/resources/channel#create-message-params 787 | */ 788 | sendMessage(channelId, message) { 789 | return this.request('post', `channels/${channelId}/messages`, { 790 | data: 791 | typeof message === 'string' ? { content: message } : { embed: message }, 792 | }); 793 | } 794 | 795 | /** 796 | * Short-hand for editing a message to Discord. 797 | * 798 | * @param {Object} message Partial Discord message. https://discordapp.com/developers/docs/resources/channel#create-message-params 799 | * @param {string} message.id Discord snowflake of the message to edit. 800 | * @param {string} message.channel_id Discord snowflake of the channel the message is in. 801 | * @param {string|Object} message When a string is passed for `message`, that string will populate the `content` field. https://discordapp.com/developers/docs/resources/channel#create-message-params 802 | */ 803 | editMessage(message, newMessage) { 804 | return this.request({ 805 | method: 'patch', 806 | url: `channels/${message.channel_id}/messages/${message.id}`, 807 | data: 808 | typeof newMessage === 'string' 809 | ? { content: newMessage } 810 | : { embed: newMessage }, 811 | }); 812 | } 813 | 814 | /** 815 | * Fetch a member using the Rest API 816 | * 817 | * @param {string|Guild} guild The guild object or id of the member. 818 | * @param {string} memberId User id of the member. 819 | */ 820 | fetchMember(guild, memberId) { 821 | let guildID; 822 | 823 | if (typeof guild === 'string') { 824 | guildID = guild; 825 | guild = this.guilds.get(guildID); 826 | } else { 827 | ({ id: guildID } = guild); 828 | } 829 | const res = this.request('get', `/guilds/${guildID}/members/${memberId}`); 830 | 831 | if (res.status === 200) { 832 | guild.upsertMember(res.data, this); 833 | } 834 | 835 | return res; 836 | } 837 | }; 838 | -------------------------------------------------------------------------------- /src/clients/Paracord/ShardLauncher.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | 'use strict'; 4 | 5 | const pm2 = require('pm2'); 6 | const Api = require('../Api/'); 7 | 8 | function validateShard(shard, shardCount) { 9 | if (shard > shardCount - 1) { 10 | throw Error(`shard id ${shard} exceeds max shard id of ${shardCount - 1}`); 11 | } 12 | } 13 | 14 | /** A script that spawns shards into pm2, injecting shard information into the Paracord client. */ 15 | module.exports = class ShardLauncher { 16 | /** 17 | * Creates a new shard launcher. 18 | * 19 | * @param {string} main Relative location of the app's entry file. 20 | * @param {ShardLauncherOptions} options Optional parameters for this handler. 21 | */ 22 | constructor(main, options) { 23 | ShardLauncher.validateParams(main, options); 24 | 25 | /** @type {string} Relative location of the app's entry point. */ 26 | this.main = main; 27 | /** @type {InternalShardIds} Ids of the shards to start internally. Ignored if `shardChunks` is defined. */ 28 | this.shardIds; 29 | /** @type {InternalShardIds[]} Arrays of shard Ids to launch. Each item will spawn a pm2 process with the designated shards internally. */ 30 | this.shardChunks; 31 | /** @type {number} Total number of shards this app will be running across all instances. */ 32 | this.shardCount; 33 | 34 | /** @type {Object} Additional environment variables to load into the app. */ 35 | this.env; 36 | /** @type {string} Name that will appear beside the shard number in pm2. */ 37 | this.appName = options.appName !== undefined ? options.appName : 'Discord Bot'; 38 | 39 | /** @type {string} Discord token. Used to find recommended shard count. Will be coerced into a bot token. */ 40 | this.token; 41 | /** @type {number} Number of shards to be launched. */ 42 | this.launchCount; 43 | 44 | Object.assign(this, options); 45 | 46 | this.bindCallbackFunctions(); 47 | } 48 | 49 | /** 50 | * Binds `this` to functions used in callbacks. 51 | * @private 52 | */ 53 | bindCallbackFunctions() { 54 | this.detach = this.detach.bind(this); 55 | } 56 | 57 | /** 58 | * Throws errors and warns if the parameters passed to the constructor aren't sufficient. 59 | * @private 60 | */ 61 | static validateParams(main, options) { 62 | const { 63 | token, shardIds, shardCount, shardChunks, 64 | } = options; 65 | 66 | if (main === undefined) { 67 | throw Error( 68 | "Main must be defined. Please provide the path to your app's entry file.", 69 | ); 70 | } 71 | if (token === undefined && shardCount === undefined) { 72 | throw Error('Must provide either a token or shardCount in the options.'); 73 | } 74 | if (shardCount <= 0) { 75 | throw Error('Shard count may not be less than or equal to 0.'); 76 | } 77 | 78 | if (shardCount && shardIds === undefined) { 79 | console.warn('Shard Ids given without shard count. shardCount will be assumed from Discord and may change in the future. It is recommended that shardCount be defined to avoid unexpected changes.'); 80 | } 81 | if (shardIds !== undefined && shardChunks === undefined) { 82 | console.warn('shardIds defined without shardCount. Ignoring shardIds.'); 83 | } 84 | if (shardIds && shardChunks) { 85 | console.warn('shardChunks defined. Ignoring shardIds.'); 86 | } 87 | 88 | if (shardChunks && shardCount) { 89 | shardChunks.forEach((c) => { 90 | c.forEach((s) => { 91 | validateShard(s, shardCount); 92 | }); 93 | }); 94 | } else if (shardIds && shardCount) { 95 | shardIds.forEach((s) => { 96 | validateShard(s, shardCount); 97 | }); 98 | } 99 | } 100 | 101 | /** 102 | * Launches shards. 103 | * 104 | * @param {import("pm2").StartOptions} pm2Options 105 | */ 106 | async launch(pm2Options = {}) { 107 | let { shardCount, shardIds, shardChunks } = this; 108 | 109 | if (shardChunks === undefined && shardCount === undefined) { 110 | ({ shardCount, shardIds } = await this.getShardInfo()); 111 | } 112 | 113 | if (shardIds && shardCount) { 114 | shardIds.forEach((s) => { 115 | validateShard(s, shardCount); 116 | }); 117 | } 118 | 119 | try { 120 | pm2.connect((err) => { 121 | if (err) { 122 | console.error(err); 123 | process.exit(2); 124 | } 125 | 126 | if (shardChunks !== undefined) { 127 | this.launchCount = shardChunks.length; 128 | shardChunks.forEach((s) => { 129 | this.launchShard(s, shardCount, pm2Options); 130 | }); 131 | } else { 132 | this.launchCount = 1; 133 | this.launchShard(shardIds, shardCount, pm2Options); 134 | } 135 | }); 136 | } catch (err) { 137 | console.error(err); 138 | } 139 | } 140 | 141 | /** 142 | * Fills missing shard information. 143 | * @private 144 | */ 145 | async getShardInfo() { 146 | console.log('Retrieving shard information from API.'); 147 | const shardCount = await this.getRecommendedShards(); 148 | 149 | console.log( 150 | `Using Discord recommended shard count: ${shardCount} shard${ 151 | shardCount > 0 ? 's' : '' 152 | }`, 153 | ); 154 | 155 | const shardIds = []; 156 | for (let i = 0; i < shardCount; ++i) { 157 | shardIds.push(i); 158 | } 159 | 160 | return { shardCount, shardIds }; 161 | } 162 | 163 | launchShard(shardIds, shardCount, pm2Options) { 164 | const shardIdsCsv = shardIds.join(','); 165 | const paracordEnv = { 166 | PARACORD_TOKEN: this.token, 167 | PARACORD_SHARD_COUNT: shardCount, 168 | PARACORD_SHARD_IDS: shardIdsCsv, 169 | }; 170 | 171 | const pm2Config = { 172 | name: `${this.appName} - Shards ${shardIdsCsv}`, 173 | script: this.main, 174 | env: { 175 | ...(this.env || {}), 176 | ...paracordEnv, 177 | }, 178 | ...pm2Options, 179 | }; 180 | 181 | pm2.start(pm2Config, this.detach); 182 | } 183 | 184 | /** Gets the recommended shard count from Discord. */ 185 | async getRecommendedShards() { 186 | const api = new Api(this.token); 187 | const { status, statusText, data } = await api.request( 188 | 'get', 189 | 'gateway/bot', 190 | ); 191 | 192 | if (status === 200) { 193 | return data.shards; 194 | } 195 | throw Error( 196 | `Failed to get shard information from API. Status ${status}. Status text: ${statusText}. Discord code: ${data.code}. Discord message: ${data.message}.`, 197 | ); 198 | } 199 | 200 | /** 201 | * Disconnects from pm2 when all chunks have been launched. 202 | * @private 203 | */ 204 | detach(err) { 205 | if (--this.launchCount === 0) { 206 | console.log('All chunks launched. Disconnecting from pm2.'); 207 | pm2.disconnect(); 208 | } 209 | 210 | if (err) throw err; 211 | } 212 | }; 213 | -------------------------------------------------------------------------------- /src/clients/Paracord/eventFuncs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SECOND_IN_MILLISECONDS } = require('../../utils/constants'); 4 | 5 | const Guild = require('./structures/Guild'); 6 | const { 7 | PARACORD_UPDATE_USER_WAIT_MILLISECONDS, 8 | CHANNEL_TYPES, 9 | } = require('../../utils/constants'); 10 | 11 | /** The methods in ALL_CAPS correspond to a Discord gateway event (https://discordapp.com/developers/docs/topics/gateway#commands-and-events-gateway-events) and are called in the Paracord `.eventHandler()` method. */ 12 | 13 | /** 14 | * @private 15 | * @this {Paracord} 16 | * @param {Object} data From Discord. 17 | */ 18 | exports.READY = function READY(data) { 19 | this.handleReady(data); 20 | 21 | return data; 22 | }; 23 | 24 | /** 25 | * @private 26 | * @this {Paracord} 27 | * @param {Object} data From Discord. 28 | */ 29 | exports.PRESENCE_UPDATE = function PRESENCE_UPDATE(data) { 30 | this.handlePresence(this.guilds.get(data.guild_id), data); 31 | 32 | return data; 33 | }; 34 | 35 | /** 36 | * @private 37 | * @this {Paracord} 38 | * @param {Object} data From Discord. 39 | */ 40 | exports.USER_UPDATE = function USER_UPDATE(data) { 41 | if (!this.veryRecentlyUpdatedUsers.has(data.id)) { 42 | this.upsertUser(data); 43 | 44 | this.veryRecentlyUpdatedUsers.set( 45 | data.id, 46 | new Date().getTime() + PARACORD_UPDATE_USER_WAIT_MILLISECONDS, 47 | ); 48 | } 49 | 50 | return data; 51 | }; 52 | 53 | /** 54 | * @private 55 | * @this {Paracord} 56 | * @param {Object} data From Discord. 57 | */ 58 | exports.MESSAGE_CREATE = function MESSAGE_CREATE(data) { 59 | if (data.member !== undefined) { 60 | data.member.user = data.author; 61 | this.cacheMemberFromEvent( 62 | this.guilds.get(data.guild_id), 63 | data.member, 64 | ); 65 | } 66 | 67 | return data; 68 | }; 69 | /** 70 | * @private 71 | * @this {Paracord} 72 | * @param {Object} data From Discord. 73 | */ 74 | exports.MESSAGE_EDIT = function MESSAGE_EDIT(data) { 75 | if (data.member !== undefined) { 76 | data.member.user = data.author; 77 | return this.cacheMemberFromEvent( 78 | this.guilds.get(data.guild_id), 79 | data.member, 80 | ); 81 | } 82 | 83 | return data; 84 | }; 85 | 86 | /** 87 | * @private 88 | * @this {Paracord} 89 | * @param {Object} data From Discord. 90 | */ 91 | exports.MESSAGE_DELETE = function MESSAGE_DELETE(data) { 92 | if (data.member !== undefined) { 93 | data.member.user = data.author; 94 | return this.cacheMemberFromEvent( 95 | this.guilds.get(data.guild_id), 96 | data.member, 97 | ); 98 | } 99 | 100 | return data; 101 | }; 102 | 103 | /** 104 | * @private 105 | * @this {Paracord} 106 | * @param {Object} data From Discord. 107 | */ 108 | exports.VOICE_STATE_UPDATE = function VOICE_STATE_UPDATE(data) { 109 | const guild = this.guilds.get(data.guild_id); 110 | 111 | if (guild) { 112 | if (data.channel_id !== null) { 113 | Guild.upsertVoiceState(guild.voiceStates, data, guild, this); 114 | } else { 115 | guild.voiceStates.delete(data.user_id); 116 | } 117 | } 118 | 119 | return this.cacheMemberFromEvent(this.guilds.get(data.guild_id), data.member); 120 | }; 121 | 122 | /** 123 | * @private 124 | * @this {Paracord} 125 | * @param {Object} data From Discord. 126 | */ 127 | exports.GUILD_MEMBER_ADD = function GUILD_MEMBER_ADD(data) { 128 | const guild = this.guilds.get(data.guild_id); 129 | if (guild) { 130 | guild.upsertMember(data, this); 131 | ++guild.member_count; 132 | } 133 | 134 | return data; 135 | }; 136 | 137 | /** 138 | * @private 139 | * @this {Paracord} 140 | * @param {Object} data From Discord. 141 | */ 142 | exports.GUILD_MEMBER_UPDATE = function GUILD_MEMBER_UPDATE(data) { 143 | const guild = this.guilds.get(data.guild_id); 144 | if (guild) { 145 | guild.upsertMember(data, this); 146 | } 147 | return data; 148 | }; 149 | 150 | /** 151 | * @private 152 | * @this {Paracord} 153 | * @param {Object} data From Discord. 154 | */ 155 | exports.GUILD_MEMBER_REMOVE = function GUILD_MEMBER_REMOVE(data) { 156 | const guild = this.guilds.get(data.guild_id); 157 | if (guild) { 158 | guild.members.delete(data.user.id); 159 | guild.presences.delete(data.user.id); 160 | --guild.member_count; 161 | } 162 | 163 | return data; 164 | }; 165 | 166 | /** 167 | * @private 168 | * @this {Paracord} 169 | * @param {Object} data From Discord. 170 | */ 171 | exports.GUILD_MEMBERS_CHUNK = function GUILD_MEMBERS_CHUNK(data) { 172 | const guild = this.guilds.get(data.guild_id); 173 | if (data.presences !== undefined) { 174 | data.presences.forEach((p) => this.handlePresence(guild, p)); 175 | } 176 | data.members.forEach((m) => this.cacheMemberFromEvent(guild, m)); 177 | 178 | return data; 179 | }; 180 | 181 | /** 182 | * @private 183 | * @this {Paracord} 184 | * @param {Object} data From Discord. 185 | */ 186 | exports.CHANNEL_CREATE = function CHANNEL_CREATE(data) { 187 | if (data.type !== CHANNEL_TYPES.DM && data.type !== CHANNEL_TYPES.GROUP_DM) { 188 | const guild = this.guilds.get(data.guild_id); 189 | if (guild) { 190 | Guild.upsertChannel(guild.channels, data); 191 | } 192 | } 193 | 194 | return data; 195 | }; 196 | 197 | /** 198 | * @private 199 | * @this {Paracord} 200 | * @param {Object} data From Discord. 201 | */ 202 | exports.CHANNEL_UPDATE = function CHANNEL_UPDATE(data) { 203 | const guild = this.guilds.get(data.guild_id); 204 | if (guild) { 205 | Guild.upsertChannel(guild.channels, data); 206 | } 207 | 208 | return data; 209 | }; 210 | 211 | /** 212 | * @private 213 | * @this {Paracord} 214 | * @param {Object} data From Discord. 215 | */ 216 | exports.CHANNEL_DELETE = function CHANNEL_DELETE(data) { 217 | const guild = this.guilds.get(data.guild_id); 218 | guild.channels.delete(data.id); 219 | 220 | return data; 221 | }; 222 | 223 | /** 224 | * @private 225 | * @this {Paracord} 226 | * @param {Object} data From Discord. 227 | */ 228 | exports.GUILD_ROLE_CREATE = function GUILD_ROLE_CREATE(data) { 229 | const guild = this.guilds.get(data.guild_id); 230 | Guild.upsertRole(guild.roles, data.role); 231 | 232 | return data; 233 | }; 234 | 235 | /** 236 | * @private 237 | * @this {Paracord} 238 | * @param {Object} data From Discord. 239 | */ 240 | exports.GUILD_ROLE_UPDATE = function GUILD_ROLE_UPDATE(data) { 241 | const guild = this.guilds.get(data.guild_id); 242 | Guild.upsertRole(guild.roles, data.role); 243 | 244 | return data; 245 | }; 246 | 247 | /** 248 | * @private 249 | * @this {Paracord} 250 | * @param {Object} data From Discord. 251 | */ 252 | exports.GUILD_ROLE_DELETE = function GUILD_ROLE_DELETE(data) { 253 | const guild = this.guilds.get(data.guild_id); 254 | guild.roles.delete(data.role_id); 255 | 256 | return data; 257 | }; 258 | 259 | /** 260 | * @private 261 | * @this {Paracord} 262 | * @param {Object} data From Discord. 263 | */ 264 | exports.GUILD_CREATE = function GUILD_CREATE(data, shard) { 265 | data._shard = shard; 266 | return this.upsertGuild(data); 267 | }; 268 | 269 | /** 270 | * @private 271 | * @this {Paracord} 272 | * @param {Object} data From Discord. 273 | */ 274 | exports.GUILD_UPDATE = function GUILD_UPDATE(data) { 275 | return this.upsertGuild(data); 276 | }; 277 | 278 | /** 279 | * @private 280 | * @this {Paracord} 281 | * @param {Object} data From Discord. 282 | */ 283 | exports.GUILD_DELETE = function GUILD_DELETE(data) { 284 | const guild = this.guilds.get(data.id); 285 | if (guild !== undefined) { 286 | if (!data.unavailable) { 287 | this.guilds.delete(data.id); 288 | return guild; 289 | } 290 | return guild; 291 | } 292 | 293 | if (data.unavailable) { 294 | return this.upsertGuild(data); 295 | } 296 | 297 | return data; 298 | }; 299 | 300 | /** 301 | * @private 302 | * @this {Paracord} 303 | * @param {Identity} identity From a gateway client. 304 | */ 305 | exports.GATEWAY_IDENTIFY = function GATEWAY_IDENTIFY(identity) { 306 | this.safeGatewayIdentifyTimestamp = new Date().getTime() + (5 * SECOND_IN_MILLISECONDS); 307 | 308 | const { shard: { 0: shard } } = identity; 309 | for (const guild of this.guilds.values()) { 310 | if (guild.shard === shard) { 311 | this.guilds.delete(guild.id); 312 | } 313 | } 314 | }; 315 | 316 | /** 317 | * @private 318 | * @this {Paracord} 319 | * @param {Identity} identity From a gateway client. 320 | */ 321 | exports.GATEWAY_CLOSE = function GATEWAY_CLOSE({ shouldReconnect, gateway }) { 322 | if (shouldReconnect) { 323 | this.gatewayLoginQueue.push(gateway); 324 | 325 | if (gateway.shard === this.startingGateway.shard) { 326 | this.startingGateway.releaseIdentifyLocks(); 327 | this.startingGateway = null; 328 | } 329 | } 330 | }; 331 | -------------------------------------------------------------------------------- /src/clients/Paracord/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "main": "./Paracord.js" 4 | } -------------------------------------------------------------------------------- /src/clients/Paracord/structures/Guild.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Utils = require('../../../utils'); 4 | const { PERMISSIONS: P } = require('../../../utils/constants'); 5 | 6 | /** A Discord guild. */ 7 | module.exports = class Guild { 8 | /** 9 | * Creates a new guild object. 10 | * 11 | * @param {Object} guildData From Discord - The guild. https://discordapp.com/developers/docs/resources/guild#guild-object 12 | * @param {Paracord} client Paracord client. 13 | */ 14 | constructor(guildData, client) { 15 | /** @type {Map>>} Cached member objects of this guild. */ 16 | this.members; 17 | /** @type {Map>} Cached channel objects of this guild. */ 18 | this.channels; 19 | /** @type {Map>>} Cached presence objects of this guild. */ 20 | this.presences; 21 | /** @type {Map>>} Cached role objects of this guild. */ 22 | this.roles; 23 | /** @type {Map>>} Cached voice state objects of this guild. */ 24 | this.voiceStates; 25 | /** @type {boolean} If this guild is currently in an unavailable state. */ 26 | this.unavailable; 27 | /** @type {void|Object} The guild owner's member object if cached. */ 28 | this.owner; 29 | /** @type {Object} The bot's member object. */ 30 | this.me; 31 | /** @type {number} The epoch timestamp of when this guild was created extract from its Id. */ 32 | this.created_on; 33 | /** @type {number} Gateway shard that the guild is a part of. */ 34 | this._shard; 35 | 36 | this.constructorDefaults(guildData, client); 37 | } 38 | 39 | get shard() { 40 | return this._shard; 41 | } 42 | 43 | /* 44 | ******************************** 45 | ********* CONSTRUCTOR ********** 46 | ******************************** 47 | */ 48 | 49 | /** 50 | * Assigns default values to this guild. 51 | * @private 52 | * 53 | * @param {Object} guildData From Discord - The guild. https://discordapp.com/developers/docs/resources/guild#guild-object 54 | * @param {Paracord} client Paracord client. 55 | */ 56 | constructorDefaults(guildData, client) { 57 | const defaults = { 58 | members: new Map(), 59 | channels: new Map(), 60 | presences: new Map(), 61 | voiceStates: new Map(), 62 | roles: new Map(), 63 | unavailable: false, 64 | }; 65 | 66 | Object.assign(this, defaults); 67 | 68 | this.constructGuildFromData(guildData, client); 69 | } 70 | 71 | /** 72 | * Add guild to client cache along with any channels. roles, members, and their presences if applicable. 73 | * @private 74 | * 75 | * @param {Object} guildData From Discord - The guild. https://discordapp.com/developers/docs/resources/guild#guild-object 76 | * @param {Paracord} client Paracord client. 77 | */ 78 | constructGuildFromData(guildData, client) { 79 | if (!guildData.unavailable) { 80 | this.unavailable = false; 81 | 82 | if (this.created_on === undefined) { 83 | this.created_on = Utils.timestampFromSnowflake(guildData.id); 84 | } 85 | 86 | this.assignFromGuildCreate(guildData, client); 87 | 88 | this.owner = this.members.get(guildData.owner_id); 89 | 90 | if (this.me === undefined) { 91 | this.me = this.members.get(client.user.id); 92 | if (this.me === undefined) { 93 | console.log( 94 | 'This message is intentional and is made to appear when a guild is created but the bot user was not included in the initial member list.', 95 | ); 96 | // Guild.lazyLoadGuildMe(client); 97 | } 98 | } 99 | } else { 100 | guildData.unavailable = true; 101 | } 102 | 103 | Object.assign(this, guildData); 104 | } 105 | 106 | /** 107 | * Replace caches with newly received information about a guild. 108 | * @private 109 | * 110 | * @param {Object} guildData From Discord - The guild. https://discordapp.com/developers/docs/resources/guild#guild-object 111 | * @param {Paracord} client Paracord client. 112 | */ 113 | assignFromGuildCreate(guildData, client) { 114 | if (guildData.channels !== undefined) { 115 | this.channels = Guild.mapChannels(guildData.channels); 116 | delete guildData.channels; 117 | } 118 | 119 | if (guildData.roles !== undefined) { 120 | this.roles = Guild.mapRoles(guildData.roles); 121 | delete guildData.roles; 122 | } 123 | 124 | if (guildData.members !== undefined) { 125 | Guild.mapMembers(guildData.members, this, client); 126 | delete guildData.members; 127 | } 128 | 129 | if (guildData.voice_states !== undefined) { 130 | this.voiceStates = Guild.mapVoiceStates( 131 | guildData.voice_states, 132 | this, 133 | client, 134 | ); 135 | } 136 | 137 | if (guildData.presences !== undefined) { 138 | this.presences = Guild.mapPresences(guildData.presences, client); 139 | delete guildData.presences; 140 | } 141 | } 142 | 143 | /* 144 | ******************************** 145 | *********** PUBLIC ************ 146 | ******************************** 147 | */ 148 | 149 | /** 150 | * Checks if the user has a specific permission in this guild. 151 | * 152 | * @param {number} permission Bit value to check for. 153 | * @param {Object} member Member whose perms to check. 154 | * @param {} [channelId] Channel to check overwrites. 155 | * @param {boolean} [adminOverride=true] Whether or not Adminstrator permissions can return `true`. 156 | * @returns {boolean} `true` if member has the permission. 157 | */ 158 | hasPermission(permission, member, adminOverride = true) { 159 | const perms = Utils.computeGuildPerms(member, this, adminOverride); 160 | 161 | if (perms & P.ADMINISTRATOR && adminOverride) { 162 | return true; 163 | } 164 | return Boolean(perms ^ permission); 165 | } 166 | 167 | /** 168 | * Checks if the user has a specific permission for a channel in this guild. 169 | * 170 | * @param {number} permission Bit value to check for. 171 | * @param {Object} member Member whose perms to check. 172 | * @param {string|Object} channe Channel to check overwrites. 173 | * @param {boolean} [adminOverride=true] Whether or not Adminstrator permissions can return `true`. 174 | * @returns {boolean} `true` if member has the permission. 175 | */ 176 | hasChannelPermission(permission, member, channel, adminOverride = true) { 177 | if (typeof channel === 'string') { 178 | channel = this.channels.get(channel); 179 | } 180 | 181 | const perms = Utils.computeChannelPerms( 182 | member, 183 | this, 184 | channel.adminOverride, 185 | ); 186 | 187 | if (perms & P.ADMINISTRATOR && adminOverride) { 188 | return true; 189 | } 190 | return (perms ^ permission) !== 0; 191 | } 192 | 193 | /* 194 | ******************************** 195 | *********** CACHING ************ 196 | ******************************** 197 | */ 198 | 199 | /** 200 | * Create a map of channels keyed to their ids. 201 | * @private 202 | * 203 | * @param {Object[]} channels https://discordapp.com/developers/docs/resources/channel#channel-object-channel-structure 204 | */ 205 | static mapChannels(channels) { 206 | const channelMap = new Map(); 207 | channels.forEach((c) => Guild.upsertChannel(channelMap, c)); 208 | return channelMap; 209 | } 210 | 211 | /** 212 | * Add a channel with some additional information to a map of channels. 213 | * @private 214 | * 215 | * @param {Map} channels Map of channels keyed to their ids. 216 | * @param {Object} channel https://discordapp.com/developers/docs/resources/channel#channel-object-channel-structure 217 | */ 218 | static upsertChannel(channels, channel) { 219 | channel.created_on = Utils.timestampFromSnowflake(channel.id); 220 | channels.set( 221 | channel.id, 222 | Object.assign(channels.get(channel.id) || {}, channel), 223 | ); 224 | } 225 | 226 | /** 227 | * Create a map of roles keyed to their ids. 228 | * @private 229 | * 230 | * @param {Object[]} roles https://discordapp.com/developers/docs/topics/permissions#role-object-role-structure 231 | */ 232 | static mapRoles(roles) { 233 | const roleMap = new Map(); 234 | roles.forEach((r) => Guild.upsertRole(roleMap, r)); 235 | return roleMap; 236 | } 237 | 238 | /** 239 | * Add a role with some additional information to a map of roles. 240 | * @private 241 | * 242 | * @param {Map} roles Map of roles keyed to their ids. 243 | * @param {Object} role https://discordapp.com/developers/docs/topics/permissions#role-object-role-structure 244 | */ 245 | static upsertRole(roles, role) { 246 | role.created_on = Utils.timestampFromSnowflake(role.id); 247 | roles.set(role.id, Object.assign(roles.get(role.id) || {}, role)); 248 | } 249 | 250 | /** 251 | * Create a map of voice states keyed to their user's id. 252 | * @private 253 | * 254 | * @param {Object[]} voiceStates https://discordapp.com/developers/docs/resources/voice 255 | */ 256 | static mapVoiceStates(voiceStates, guild, client) { 257 | const voiceStateMap = new Map(); 258 | voiceStates.forEach((v) => Guild.upsertVoiceState(voiceStateMap, v, guild, client)); 259 | return voiceStateMap; 260 | } 261 | 262 | /** 263 | * Add a role to a map of voice states. 264 | * @private 265 | * 266 | * @param {Map} voiceStates Map of user id to voice states. 267 | * @param {Object} voiceState https://discordapp.com/developers/docs/resources/voice 268 | * @param {Guild} guild 269 | * @param {Paracord} client 270 | */ 271 | static upsertVoiceState(voiceStates, voiceState, guild, client) { 272 | if (voiceState.member !== undefined) { 273 | let cachedMember = guild.members.get(voiceState.member); 274 | if (cachedMember === undefined) { 275 | cachedMember = guild.upsertMember(voiceState.member, client); 276 | } 277 | 278 | voiceState = { ...voiceState }; 279 | voiceState.member = cachedMember; 280 | } 281 | 282 | voiceStates.set(voiceState.user_id, voiceState); 283 | } 284 | 285 | /** 286 | * Create a map of presences keyed to their user's ids. 287 | * @private 288 | * 289 | * @param {Object[]} presences https://discordapp.com/developers/docs/topics/gateway#presence-update-presence-update-event-fields 290 | * @param {Paracord} client 291 | */ 292 | static mapPresences(presences, client) { 293 | const presenceMap = new Map(); 294 | presences.forEach((p) => { 295 | const cachedPresence = client.updatePresences(p); 296 | if (cachedPresence !== undefined) { 297 | presenceMap.set(cachedPresence.user.id, cachedPresence); 298 | } 299 | }); 300 | return presenceMap; 301 | } 302 | 303 | /** 304 | * Set a presence in this guild's presence cache. 305 | * @private 306 | * 307 | * @param {Object} presence 308 | */ 309 | setPresence(presence) { 310 | this.presences.set(presence.user.id, presence); 311 | } 312 | 313 | /** 314 | * Remove a presence from this guild's presence cache. 315 | * @private 316 | * 317 | * @param {string} userId 318 | */ 319 | deletePresence(userId) { 320 | this.presences.delete(userId); 321 | } 322 | 323 | /** 324 | * Cache members and create a map of them keyed to their user ids. 325 | * @private 326 | * 327 | * @param {Object[]} members https://discordapp.com/developers/docs/resources/guild#guild-member-object 328 | */ 329 | static mapMembers(members, guild, client) { 330 | members.forEach((m) => guild.upsertMember(m, client)); 331 | } 332 | 333 | /** 334 | * Add a member with some additional information to a map of members. 335 | * @private 336 | * 337 | * @param {Object} member https://discordapp.com/developers/docs/resources/guild#guild-member-object 338 | * @param {Paracord} client 339 | */ 340 | upsertMember(member, client) { 341 | const cachedUser = client.upsertUser(member.user); 342 | if (cachedUser !== undefined) { 343 | member.user = cachedUser; 344 | 345 | const cachedMember = this.members.get(member.user.id); 346 | if (cachedMember !== undefined) { 347 | Object.assign(cachedMember, member); 348 | } else { 349 | this.members.set(member.user.id, member); 350 | } 351 | 352 | return cachedMember; 353 | } 354 | 355 | return undefined; 356 | } 357 | 358 | // /** 359 | // * Asynchronously gets the the bot's member object from Discord and stores it in the guild. 360 | // * @private 361 | // * 362 | // * @param {Object} guild https://discordapp.com/developers/docs/resources/guild#guild-object 363 | // * @returns {void} guild.me <- https://discordapp.com/developers/docs/resources/guild#guild-member-object-guild-member-structure 364 | // */ 365 | // async lazyLoadGuildMe(client) { 366 | // const res = await client.fetchMember(this, client.user.id); 367 | 368 | // if (res.status === 200) { 369 | // // eslint-disable-next-line require-atomic-updates 370 | // this.me = res.data; 371 | 372 | // return this.me; 373 | // } else { 374 | // console.error(`Unable to get me for ${this.name} (ID: ${this.id}).`); 375 | // } 376 | // } 377 | }; 378 | -------------------------------------------------------------------------------- /src/clients/Paracord/structures/tests/Guild.spec.js: -------------------------------------------------------------------------------- 1 | // "use strict"; 2 | // const assert = require("assert"); 3 | // const sinon = require("sinon"); 4 | // const Guild = require("../Guild"); 5 | // const Paracord = require("../../Paracord"); 6 | // const Utils = require("../../../../utils/Util"); 7 | 8 | // describe("Guild", () => { 9 | // let g; 10 | // let c; 11 | // let clock; 12 | // const token = "Bot tok"; 13 | 14 | // beforeEach(() => { 15 | // g = new Guild({}, undefined, true); 16 | // c = new Paracord(token); 17 | // c.user = {}; 18 | 19 | // clock = sinon.useFakeTimers(); 20 | // }); 21 | 22 | // afterEach(() => { 23 | // sinon.restore(); 24 | // clock.restore(); 25 | // }); 26 | 27 | // describe("constructGuildFromData", () => { 28 | // let stub_mapChannels; 29 | // let stub_mapRoles; 30 | // let stub_mapPresences; 31 | // let stub_mapMembers; 32 | // // let stub_lazyLoadGuildOwner; 33 | // let stub_lazyLoadguileMe; 34 | // let stub_timestampFromSnowflake; 35 | 36 | // beforeEach(() => { 37 | // stub_mapChannels = sinon.stub(Guild, "mapChannels"); 38 | // stub_mapRoles = sinon.stub(Guild, "mapRoles"); 39 | // stub_mapPresences = sinon.stub(Guild, "mapPresences"); 40 | // stub_mapMembers = sinon.stub(Guild, "mapMembers"); 41 | // // stub_lazyLoadGuildOwner = sinon.stub(Guild, "lazyLoadGuildOwner"); 42 | // stub_lazyLoadguileMe = sinon.stub(Guild, "lazyLoadGuildMe"); 43 | // stub_timestampFromSnowflake = sinon.stub(Util, "timestampFromSnowflake"); 44 | // }); 45 | 46 | // it(".", () => { 47 | // const guildData = {}; 48 | // stub_timestampFromSnowflake.returns(1); 49 | 50 | // g.constructGuildFromData(guildData, c); 51 | 52 | // sinon.assert.calledOnce(stub_timestampFromSnowflake); 53 | // sinon.assert.notCalled(stub_mapChannels); 54 | // sinon.assert.notCalled(stub_mapRoles); 55 | // sinon.assert.notCalled(stub_mapPresences); 56 | // sinon.assert.notCalled(stub_mapMembers); 57 | // // sinon.assert.calledOnce(stub_lazyLoadGuildOwner); 58 | // sinon.assert.calledOnce(stub_lazyLoadguileMe); 59 | // assert.strictEqual(g.created_on, 1); 60 | // assert.deepStrictEqual(g.channels, new Map()); 61 | // assert.deepStrictEqual(g.roles, new Map()); 62 | // assert.deepStrictEqual(g.presences, new Map()); 63 | // assert.deepStrictEqual(g.members, new Map()); 64 | // }); 65 | // it("..", () => { 66 | // const guildData = { channels: [], roles: [], presences: [], members: [] }; 67 | // const mapChannels_returns = new Map(); 68 | // const mapRoles_returns = new Map(); 69 | // const mapPresences_returns = new Map(); 70 | // // const mapMembers_returns = new Map(); 71 | // stub_mapChannels.returns(mapChannels_returns); 72 | // stub_mapRoles.returns(mapRoles_returns); 73 | // stub_mapPresences.returns(mapPresences_returns); 74 | // // stub_mapMembers.returns(mapMembers_returns); 75 | // g.created_on = 1; 76 | 77 | // g.constructGuildFromData(guildData, c); 78 | 79 | // sinon.assert.notCalled(stub_timestampFromSnowflake); 80 | // sinon.assert.calledOnce(stub_mapChannels); 81 | // sinon.assert.calledOnce(stub_mapRoles); 82 | // sinon.assert.calledOnce(stub_mapPresences); 83 | // sinon.assert.calledOnce(stub_mapMembers); 84 | // // sinon.assert.calledOnce(stub_lazyLoadGuildOwner); 85 | // sinon.assert.calledOnce(stub_lazyLoadguileMe); 86 | // assert.strictEqual(g.channels, mapChannels_returns); 87 | // assert.strictEqual(g.roles, mapRoles_returns); 88 | // assert.strictEqual(g.presences, mapPresences_returns); 89 | // // assert.strictEqual(g.members, mapMembers_returns); 90 | // }); 91 | // it("...", () => { 92 | // const guildData = {}; 93 | 94 | // g.me = null; 95 | 96 | // g.constructGuildFromData(guildData, c); 97 | 98 | // sinon.assert.notCalled(stub_lazyLoadguileMe); 99 | // }); 100 | // }); 101 | 102 | // describe("upsertChannel", () => { 103 | // it(".", () => { 104 | // const stub_timestampFromSnowflake = sinon 105 | // .stub(Util, "timestampFromSnowflake") 106 | // .returns(0); 107 | // const channels = new Map(); 108 | // const channel = { id: "1234567890987654231" }; 109 | 110 | // Guild.upsertChannel(channels, channel); 111 | 112 | // sinon.assert.calledOnce(stub_timestampFromSnowflake); 113 | // assert.strictEqual(channels.size, 1); 114 | // assert.deepStrictEqual(channels.get("1234567890987654231"), channel); 115 | // assert.strictEqual(channels.get("1234567890987654231").created_on, 0); 116 | // }); 117 | // }); 118 | 119 | // describe("mapChannels", () => { 120 | // let sub_upsertChannel; 121 | 122 | // beforeEach(() => { 123 | // sub_upsertChannel = sinon.stub(Guild, "upsertChannel"); 124 | // }); 125 | 126 | // it(".", () => { 127 | // const got = Guild.mapChannels([]); 128 | 129 | // const exp = new Map(); 130 | 131 | // sinon.assert.notCalled(sub_upsertChannel); 132 | // assert.deepStrictEqual(got, exp); 133 | // }); 134 | // it("..", () => { 135 | // const channel = {}; 136 | 137 | // const got = Guild.mapChannels([channel]); 138 | // const exp = new Map(); 139 | 140 | // sinon.assert.calledOnce(sub_upsertChannel); 141 | // assert.deepStrictEqual(got, exp); 142 | // assert.strictEqual(sub_upsertChannel.getCall(0).args[1], channel); 143 | // }); 144 | // it("...", () => { 145 | // const channel1 = {}; 146 | // const channel2 = {}; 147 | 148 | // const got = Guild.mapChannels([channel1, channel2]); 149 | // const exp = new Map(); 150 | 151 | // sinon.assert.calledTwice(sub_upsertChannel); 152 | // assert.deepStrictEqual(got, exp); 153 | // assert.strictEqual(sub_upsertChannel.getCall(0).args[1], channel1); 154 | // assert.strictEqual(sub_upsertChannel.getCall(1).args[1], channel2); 155 | // }); 156 | // }); 157 | 158 | // describe("upsertRole", () => { 159 | // it(".", () => { 160 | // const stub_timestampFromSnowflake = sinon 161 | // .stub(Util, "timestampFromSnowflake") 162 | // .returns(0); 163 | // const roles = new Map(); 164 | // const role = { id: "1234567890987654231" }; 165 | 166 | // Guild.upsertRole(roles, role); 167 | 168 | // sinon.assert.calledOnce(stub_timestampFromSnowflake); 169 | // assert.strictEqual(roles.size, 1); 170 | // assert.deepStrictEqual(roles.get("1234567890987654231"), role); 171 | // assert.strictEqual(roles.get("1234567890987654231").created_on, 0); 172 | // }); 173 | // }); 174 | 175 | // describe("mapRoles", () => { 176 | // let sub_upsertRole; 177 | 178 | // beforeEach(() => { 179 | // sub_upsertRole = sinon.stub(Guild, "upsertRole"); 180 | // }); 181 | 182 | // it(".", () => { 183 | // const got = Guild.mapRoles([]); 184 | // const exp = new Map(); 185 | 186 | // sinon.assert.notCalled(sub_upsertRole); 187 | // assert.deepStrictEqual(got, exp); 188 | // }); 189 | // it("..", () => { 190 | // const role = {}; 191 | // const got = Guild.mapRoles([role]); 192 | // const exp = new Map(); 193 | 194 | // sinon.assert.calledOnce(sub_upsertRole); 195 | // assert.deepStrictEqual(got, exp); 196 | // assert.strictEqual(sub_upsertRole.getCall(0).args[1], role); 197 | // }); 198 | // it("...", () => { 199 | // const role1 = {}; 200 | // const role2 = {}; 201 | 202 | // const got = Guild.mapRoles([role1, role2]); 203 | // const exp = new Map(); 204 | 205 | // sinon.assert.calledTwice(sub_upsertRole); 206 | // assert.deepStrictEqual(got, exp); 207 | // assert.strictEqual(sub_upsertRole.getCall(0).args[1], role1); 208 | // assert.strictEqual(sub_upsertRole.getCall(1).args[1], role2); 209 | // }); 210 | // }); 211 | 212 | // describe("upsertMember", () => { 213 | // let stub_client_upsertUser; 214 | 215 | // beforeEach(() => { 216 | // stub_client_upsertUser = sinon.stub(c, "upsertUser"); 217 | // }); 218 | 219 | // it(".", () => { 220 | // const cachedUser = { id: "abc" }; 221 | // stub_client_upsertUser.returns(cachedUser); 222 | // const cachedMember = {}; 223 | 224 | // g.members.set("abc", cachedMember); 225 | 226 | // const member = { user: { id: "abc" } }; 227 | 228 | // g.upsertMember(member, c); 229 | 230 | // assert.strictEqual(member.user, cachedUser); 231 | // assert.strictEqual(g.members.size, 1); 232 | // assert.strictEqual(g.members.get("abc"), cachedMember); 233 | // }); 234 | // it("..", () => { 235 | // const cachedUser = { id: "abc" }; 236 | // stub_client_upsertUser.returns(cachedUser); 237 | // const cachedMember = {}; 238 | 239 | // const member = { user: { id: "abc" } }; 240 | 241 | // g.upsertMember(member, c); 242 | 243 | // assert.strictEqual(member.user, cachedUser); 244 | // assert.strictEqual(g.members.size, 1); 245 | // assert.notStrictEqual(g.members.get("abc"), cachedMember); 246 | // assert.deepStrictEqual(g.members.get("abc"), { ...member }); 247 | // }); 248 | // }); 249 | 250 | // describe("mapMembers", () => { 251 | // it(".", () => { 252 | // const sub_upsertMember = sinon.stub(g, "upsertMember"); 253 | // const members = [{}]; 254 | 255 | // Guild.mapMembers(members, g, c); 256 | 257 | // sinon.assert.calledOnce(sub_upsertMember); 258 | // }); 259 | // }); 260 | 261 | // describe("mapPresences", () => { 262 | // let stub_paracord_updatePresences; 263 | 264 | // beforeEach(() => { 265 | // stub_paracord_updatePresences = sinon.stub(c, "updatePresences"); 266 | // }); 267 | 268 | // it(".", () => { 269 | // const presences = []; 270 | 271 | // const got = Guild.mapPresences(presences, c); 272 | // const exp = new Map(); 273 | 274 | // sinon.assert.notCalled(stub_paracord_updatePresences); 275 | // assert.deepStrictEqual(got, exp); 276 | // }); 277 | // it("..", () => { 278 | // const presence = { user: { id: "123" } }; 279 | // stub_paracord_updatePresences.returns(presence); 280 | // const presences = [{}]; 281 | 282 | // const got = Guild.mapPresences(presences, c); 283 | 284 | // sinon.assert.calledOnce(stub_paracord_updatePresences); 285 | // assert.deepStrictEqual(got.size, 1); 286 | // assert.deepStrictEqual(got.get("123"), presence); 287 | // }); 288 | // }); 289 | // }); 290 | -------------------------------------------------------------------------------- /src/rpc/protobufs/identify_lock.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | /* This service is used by clients to offset their gateway identifies. 4 | * Used to navigate Discord gateway's identify rate limit and throttle identifies on the same host. */ 5 | service LockService { 6 | rpc acquire(LockRequestMessage) returns (StatusMessage); 7 | rpc release(TokenMessage) returns (StatusMessage); 8 | } 9 | 10 | /* TokenMessage repesents a unique identifier given to the client by the server on a successful lock(). 11 | * Used as authorization to perform operations on the existing lock.. */ 12 | message TokenMessage { 13 | string value = 1; 14 | } 15 | 16 | /* LockRequest represents a request to the server to acquire the lock. */ 17 | message LockRequestMessage { 18 | int32 time_out = 1; // How long the lock should stay active before releasing. 19 | string token = 2; 20 | } 21 | 22 | /* StatusMessage represents the server's response to the client's requested action. */ 23 | message StatusMessage { 24 | bool success = 1; // Whether or not the actions was successful. 25 | string token = 2; 26 | string message = 3; // On unsuccessful action, the reason why. 27 | } -------------------------------------------------------------------------------- /src/rpc/protobufs/rate_limit.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | /* This service is used by clients to navigate rate limits while handling requests locally. */ 4 | service RateLimitService { 5 | rpc authorize(RequestMetaMessage) returns (AuthorizationMessage); 6 | rpc update(RateLimitStateMessage) returns (Empty); 7 | } 8 | 9 | message Empty {} 10 | 11 | /* RequestMetaMessage represents the meta data of a client's request that is used in determining rate limits. */ 12 | message RequestMetaMessage { 13 | string method = 1; 14 | string url = 2; 15 | } 16 | 17 | /* AuthorizationMessage represents a server's response to a client's request to authorize a request. */ 18 | message AuthorizationMessage { 19 | uint32 reset_after = 1; // How long the client should wait in ms before asking to authorize the request again, if at all. 0 if the request is authorized. 20 | } 21 | 22 | /* RateLimitStateMessage represents the information of a rate limit, received in the response from Discord. */ 23 | message RateLimitStateMessage { 24 | RequestMetaMessage request_meta = 1; 25 | bool global = 2; 26 | string bucket = 3; 27 | uint32 limit = 4; 28 | uint32 remaining = 5; 29 | uint32 reset_after = 6; 30 | } -------------------------------------------------------------------------------- /src/rpc/protobufs/request.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | /* This service is used by clients who wish to make Discord API requests from a central server that handles rate limit logic. */ 4 | service RequestService { 5 | rpc request(RequestMessage) returns (ResponseMessage); 6 | } 7 | 8 | /* Request represents a client's Discord API request. 9 | * It contains the necessary information for the server to make the request on its behalf. */ 10 | message RequestMessage { 11 | string method = 1; // HTTP method 12 | string url = 2; // Discord endpoint url. (e.g. channels/123) 13 | string data = 3; // JSON encoded data to send wtih request. 14 | string headers = 4; // JSON encoded headers to send with request. 15 | } 16 | 17 | /* Response represents Discord's response to the client's request. */ 18 | message ResponseMessage { 19 | uint32 status_code = 1; // HTTP status code. 20 | string status_text = 2; // Status message from Discord. 21 | string data = 3; // JSON encoded data from Discord. 22 | } -------------------------------------------------------------------------------- /src/rpc/server/Server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sync */ 2 | 3 | 'use strict'; 4 | 5 | const grpc = require('@grpc/grpc-js'); 6 | 7 | const { 8 | identifyLockCallbacks, 9 | requestCallbacks, 10 | rateLimitCallbacks, 11 | } = require('../services'); 12 | 13 | const { loadProto } = require('../services/common'); 14 | const { Lock } = require('../structures'); 15 | 16 | const Api = require('../../clients/Api'); 17 | const { RateLimitCache } = require('../../clients/Api/structures'); 18 | 19 | const { LOG_SOURCES, LOG_LEVELS } = require('../../utils/constants'); 20 | 21 | const requestProto = loadProto('request'); 22 | const lockProto = loadProto('identify_lock'); 23 | const rateLimitProto = loadProto('rate_limit'); 24 | 25 | /** 26 | * Rpc server. 27 | * @extends grpc.Server 28 | */ 29 | module.exports = class Server extends grpc.Server { 30 | /** 31 | * Creates a new rpc Server. 32 | * 33 | * @param {RpcServerOptions} options 34 | */ 35 | constructor(options = {}) { 36 | super(); 37 | /** @type {import("events").EventEmitter} Emitter for debug logging. */ 38 | this.emitter; 39 | /** @type {RpcServerBindArgs} Arguments passed when binding the server to its port. */ 40 | this.bindArgs; 41 | 42 | /** @type {void|Api} Api client when the "request" service is added. */ 43 | this.apiClient; 44 | /** @type {void|Lock} Lock instance when the "identify lock" service is added. */ 45 | this.identifyLock; 46 | 47 | this.constructorDefaults(options); 48 | } 49 | 50 | /** 51 | * Assigns default values to this rpc server based on the options. 52 | * @private 53 | * 54 | * @param {RpcServerOptions} options 55 | */ 56 | constructorDefaults(options) { 57 | const defaults = { 58 | host: '127.0.0.1', 59 | port: '50051', 60 | channel: grpc.ServerCredentials.createInsecure(), 61 | ...options, 62 | }; 63 | 64 | Object.assign(this, defaults); 65 | 66 | this.bindArgs = this.bindArgs || this.createDefaultBindArgs(); 67 | } 68 | 69 | /** 70 | * Establishes the arguments that will be passed to `bindAsync()` when starting the server. 71 | * @private 72 | */ 73 | createDefaultBindArgs() { 74 | const callback = () => { 75 | try { 76 | this.start(); 77 | } catch (err) { 78 | if (err.message === 'server must be bound in order to start') { 79 | console.error('server must be bound in order to start. maybe this host:port is already bound?'); 80 | } 81 | } 82 | 83 | const message = `Rpc server running at http://${this.host}:${this.port}`; 84 | this.emit('DEBUG', { 85 | source: LOG_SOURCES.RPC, 86 | level: LOG_LEVELS.INFO, 87 | message, 88 | }); 89 | }; 90 | 91 | return [`${this.host}:${this.port}`, this.channel, callback]; 92 | } 93 | 94 | /** 95 | * Emits logging events. 96 | * @private 97 | * 98 | * @param {...any} args Arguments to pass directly into the emitter. 99 | */ 100 | emit(...args) { 101 | if (this.emitter !== undefined) { 102 | this.emitter.emit(...args); 103 | } 104 | } 105 | 106 | /** 107 | * Adds the request service to this server. Allows the server to handle Discord API requests from clients. 108 | * 109 | * @param {string} token Discord token. Will be coerced into a bot token. 110 | * @param {ApiOptions} apiOptions Optional parameters for the api handler. 111 | */ 112 | addRequestService(token, apiOptions = {}) { 113 | apiOptions.requestOptions = apiOptions.requestOptions || {}; 114 | apiOptions.requestOptions.transformResponse = (data) => data; 115 | 116 | this.apiClient = new Api(token, apiOptions); 117 | this.apiClient.startQueue(); 118 | 119 | this.addService(requestProto.RequestService, requestCallbacks(this)); 120 | this.emit('DEBUG', { 121 | source: LOG_SOURCES.RPC, 122 | level: LOG_LEVELS.INFO, 123 | message: 'The request service has been added to the server.', 124 | }); 125 | } 126 | 127 | /** Adds the identify lock service to this server. Allows the server to maintain a lock for clients. */ 128 | addLockService() { 129 | this.identifyLock = new Lock(this.emitter); 130 | 131 | this.addService( 132 | lockProto.LockService, 133 | identifyLockCallbacks(this, this.identifyLock), 134 | ); 135 | this.emit('DEBUG', { 136 | source: LOG_SOURCES.RPC, 137 | level: LOG_LEVELS.INFO, 138 | message: 'The identify lock service has been to the server.', 139 | }); 140 | } 141 | 142 | addRateLimitService() { 143 | this.rateLimitCache = new RateLimitCache(); 144 | 145 | this.addService( 146 | rateLimitProto.RateLimitService, 147 | rateLimitCallbacks(this, this.rateLimitCache), 148 | ); 149 | this.emit('DEBUG', { 150 | source: LOG_SOURCES.RPC, 151 | level: LOG_LEVELS.INFO, 152 | message: 'The rate limit service has been to the server.', 153 | }); 154 | } 155 | 156 | /** Start the server. */ 157 | serve() { 158 | this.bindAsync(...this.bindArgs); 159 | } 160 | 161 | log(level, message) { 162 | this.emit('DEBUG', { 163 | source: LOG_SOURCES.RPC, 164 | level: LOG_LEVELS[level], 165 | message, 166 | }); 167 | } 168 | }; 169 | -------------------------------------------------------------------------------- /src/rpc/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "main": "./server.js" 4 | } -------------------------------------------------------------------------------- /src/rpc/services/common.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sync */ 2 | 3 | 'use strict'; 4 | 5 | const grpc = require('@grpc/grpc-js'); 6 | const protoLoader = require('@grpc/proto-loader'); 7 | 8 | /** 9 | * Load in a protobuf from a file. 10 | * 11 | * @param {string} proto Name of the proto file. 12 | */ 13 | exports.loadProto = function (proto) { 14 | const protopath = __filename.replace( 15 | 'services/common.js', 16 | `protobufs/${proto}.proto`, 17 | ); 18 | 19 | return protoLoader.loadSync(protopath, { 20 | keepCase: true, 21 | }); 22 | }; 23 | 24 | /** 25 | * Create the proto definition from a loaded into protobuf. 26 | * 27 | * @param {string} proto Name of the proto file. 28 | */ 29 | exports.loadProtoDefinition = function (proto) { 30 | return grpc.loadPackageDefinition(exports.loadProto(proto)); 31 | }; 32 | 33 | /** 34 | * Create the parameters passed to a service definition constructor. 35 | * 36 | * @param {ServiceOptions} options 37 | */ 38 | exports.constructorDefaults = function (options) { 39 | const host = options.host || '127.0.0.1'; 40 | const port = options.port || '50051'; 41 | const channel = options.channel || grpc.ChannelCredentials.createInsecure(); 42 | 43 | return [`${host}:${port}`, channel, { keepCase: true }]; 44 | }; 45 | -------------------------------------------------------------------------------- /src/rpc/services/identifyLock/IdentifyLockService.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | 3 | 'use strict'; 4 | 5 | const { 6 | LockRequestMessage, 7 | StatusMessage, 8 | TokenMessage, 9 | } = require('../../structures'); 10 | const { loadProtoDefinition, constructorDefaults } = require('../common'); 11 | 12 | const definition = loadProtoDefinition('identify_lock'); 13 | 14 | /** Definition for the identity lock rpc service. */ 15 | module.exports = class IdentifyLockService extends definition.LockService { 16 | /** 17 | * Creates an identity lock service. 18 | * 19 | * @param {ServiceOptions} options 20 | */ 21 | constructor(options) { 22 | const defaultArgs = constructorDefaults(options || {}); 23 | super(...defaultArgs); 24 | this.target = defaultArgs[0]; 25 | /** @type {boolean} Used by the client to determine if it should fallback to an alternative method or not. */ 26 | this.allowFallback; 27 | /** @type {number} How long in ms the client tells the server it should wait before expiring the lock. */ 28 | this.duration; 29 | /** @type {string} Unique ID given to this client when acquiring the lock. */ 30 | this.token; 31 | } 32 | 33 | /** 34 | * Sends a request to acquire the lock to the server, returning a promise with the parsed response. 35 | * @returns {Promise} 36 | */ 37 | acquire() { 38 | const message = new LockRequestMessage(this.duration, this.token).proto; 39 | 40 | return new Promise((resolve, reject) => { 41 | super.acquire(message, (err, res) => { 42 | if (err === null) { 43 | const statusMessage = StatusMessage.fromProto(res); 44 | ({ token: this.token } = statusMessage); 45 | resolve(statusMessage); 46 | } else { 47 | reject(err); 48 | } 49 | }); 50 | }); 51 | } 52 | 53 | /** 54 | * Sends a request to release the lock to the server, returning a promise with the parsed response. 55 | * @returns {Promise} 56 | */ 57 | release() { 58 | const message = new TokenMessage(this.token).proto; 59 | 60 | return new Promise((resolve, reject) => { 61 | super.release(message, (err, res) => { 62 | if (err === null) { 63 | resolve(StatusMessage.fromProto(res)); 64 | } else { 65 | reject(err); 66 | } 67 | }); 68 | }); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /src/rpc/services/identifyLock/callbacks.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable callback-return */ 2 | 3 | 'use strict'; 4 | 5 | const { LockRequestMessage, TokenMessage } = require('../../structures'); 6 | const { LOG_SOURCES, LOG_LEVELS } = require('../../../utils/constants'); 7 | 8 | /** 9 | * Create callback functions for the identify lock service. 10 | * 11 | * @param {Server} server 12 | * @param {Lock} identifyLock 13 | */ 14 | module.exports = function (server, identifyLock) { 15 | function acquire(call, callback) { 16 | try { 17 | const { timeOut, token } = LockRequestMessage.fromProto(call.request); 18 | 19 | const message = identifyLock.acquire(timeOut, token); 20 | 21 | callback(null, message); 22 | } catch (err) { 23 | server.emit('DEBUG', { 24 | source: LOG_SOURCES.RPC, 25 | level: LOG_LEVELS.ERROR, 26 | message: err.message, 27 | }); 28 | callback(err); 29 | } 30 | } 31 | 32 | function release(call, callback) { 33 | try { 34 | const { value: token } = TokenMessage.fromProto(call.request); 35 | 36 | const message = identifyLock.release(token); 37 | 38 | if (message.success !== undefined) { 39 | server.emit('DEBUG', { 40 | source: LOG_SOURCES.RPC, 41 | level: LOG_LEVELS.DEBUG, 42 | message: `Lock released by client. Token: ${token}`, 43 | }); 44 | } 45 | 46 | callback(null, message); 47 | } catch (err) { 48 | server.emit('DEBUG', { 49 | source: LOG_SOURCES.RPC, 50 | level: LOG_LEVELS.ERROR, 51 | message: err.message, 52 | }); 53 | callback(err); 54 | } 55 | } 56 | 57 | return { acquire, release }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/rpc/services/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | /* Identify Lock */ 5 | identifyLockCallbacks: require('./identifyLock/callbacks'), 6 | IdentifyLockService: require('./identifyLock/IdentifyLockService'), 7 | /* Rate Limit */ 8 | rateLimitCallbacks: require('./rateLimit/callbacks'), 9 | RateLimitService: require('./rateLimit/RateLimitService'), 10 | /* Request */ 11 | requestCallbacks: require('./request/callbacks'), 12 | RequestService: require('./request/RequestService'), 13 | }; 14 | -------------------------------------------------------------------------------- /src/rpc/services/rateLimit/RateLimitService.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | 3 | 'use strict'; 4 | 5 | const { 6 | RequestMetaMessage, 7 | AuthorizationMessage, 8 | RateLimitStateMessage, 9 | } = require('../../structures'); 10 | const { loadProtoDefinition, constructorDefaults } = require('../common'); 11 | 12 | const definition = loadProtoDefinition('rate_limit'); 13 | 14 | /** Definition for the identity lock rpc service. */ 15 | module.exports = class RateLimitService extends definition.RateLimitService { 16 | /** 17 | * Creates an identity lock service. 18 | * 19 | * @param {ServiceOptions} options 20 | */ 21 | constructor(options) { 22 | const defaultArgs = constructorDefaults(options || {}); 23 | super(...defaultArgs); 24 | this.target = defaultArgs[0]; 25 | } 26 | 27 | /** 28 | * Receives authorization from rate limit handling server to make the request. 29 | * 30 | * @param {Request} request The request being authorized. 31 | * @returns {Promise} 32 | */ 33 | authorize(request) { 34 | const { method, url } = request; 35 | 36 | const message = new RequestMetaMessage(method, url).proto; 37 | 38 | return new Promise((resolve, reject) => { 39 | super.authorize(message, (err, res) => { 40 | if (err === null) { 41 | resolve(AuthorizationMessage.fromProto(res)); 42 | } else { 43 | reject(err); 44 | } 45 | }); 46 | }); 47 | } 48 | 49 | /** 50 | * 51 | * @param {Request} request The request being authorized. 52 | * @returns {Promise} 53 | */ 54 | update(request, global, bucket, limit, remaining, resetAfter) { 55 | const { method, url } = request; 56 | const requestMeta = new RequestMetaMessage(method, url); 57 | const message = new RateLimitStateMessage( 58 | requestMeta, 59 | global, 60 | bucket, 61 | limit, 62 | remaining, 63 | resetAfter, 64 | ).proto; 65 | 66 | return new Promise((resolve, reject) => { 67 | super.update(message, (err) => { 68 | if (err === null) { 69 | resolve(); 70 | } else { 71 | reject(err); 72 | } 73 | }); 74 | }); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /src/rpc/services/rateLimit/callbacks.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable callback-return */ 2 | 3 | 'use strict'; 4 | 5 | const { 6 | RequestMetaMessage, 7 | AuthorizationMessage, 8 | RateLimitStateMessage, 9 | } = require('../../structures'); 10 | const { 11 | BaseRequest, 12 | RateLimitHeaders, 13 | } = require('../../../clients/Api/structures'); 14 | const { LOG_SOURCES, LOG_LEVELS } = require('../../../utils/constants'); 15 | 16 | /** 17 | * Create callback functions for the rate limit service. 18 | * 19 | * @param {Server} server 20 | * @param {RateLimitCache} cache 21 | */ 22 | module.exports = function (server, cache) { 23 | function authorize(call, callback) { 24 | try { 25 | const { method, url } = RequestMetaMessage.fromProto(call.request); 26 | const request = new BaseRequest(method, url); 27 | const resetAfter = cache.authorizeRequestFromClient(request); 28 | 29 | if (resetAfter === 0) { 30 | const message = `Request approved. ${method} ${url}`; 31 | server.log('DEBUG', message); 32 | } else { 33 | const message = `Request denied. ${method} ${url}`; 34 | server.log('DEBUG', message); 35 | } 36 | 37 | const message = new AuthorizationMessage(resetAfter).proto; 38 | 39 | callback(null, message); 40 | } catch (err) { 41 | server.emit('DEBUG', { 42 | source: LOG_SOURCES.RPC, 43 | level: LOG_LEVELS.ERROR, 44 | message: err, 45 | }); 46 | callback(err); 47 | } 48 | } 49 | 50 | function update(call, callback) { 51 | try { 52 | const { 53 | requestMeta, 54 | global, 55 | bucket, 56 | limit, 57 | remaining, 58 | resetAfter, 59 | } = RateLimitStateMessage.fromProto(call.request); 60 | 61 | const { method, url } = requestMeta; 62 | const request = new BaseRequest(method, url); 63 | 64 | if (bucket === undefined) { 65 | cache.update(request); 66 | } else { 67 | const rateLimitHeaders = new RateLimitHeaders( 68 | global, 69 | bucket, 70 | limit, 71 | remaining, 72 | resetAfter, 73 | ); 74 | cache.update(request, rateLimitHeaders); 75 | } 76 | 77 | const message = `Rate limit cache updated: ${method} ${url} | Remaining: ${remaining}`; 78 | server.log('DEBUG', message); 79 | 80 | callback(null); 81 | } catch (err) { 82 | server.emit('DEBUG', { 83 | source: LOG_SOURCES.RPC, 84 | level: LOG_LEVELS.ERROR, 85 | message: err.message, 86 | }); 87 | callback(err); 88 | } 89 | } 90 | 91 | return { authorize, update }; 92 | }; 93 | -------------------------------------------------------------------------------- /src/rpc/services/request/RequestService.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | 3 | 'use strict'; 4 | 5 | const { RequestMessage, ResponseMessage } = require('../../structures'); 6 | const { loadProtoDefinition, constructorDefaults } = require('../common'); 7 | 8 | const definition = loadProtoDefinition('request'); 9 | 10 | /** Definition for the request service. */ 11 | module.exports = class RequestService extends definition.RequestService { 12 | /** 13 | * Creates a request service. 14 | * 15 | * @param {ServiceOptions} options 16 | */ 17 | constructor(options) { 18 | const defaultArgs = constructorDefaults(options || {}); 19 | super(...defaultArgs); 20 | /** @type {string} host:port the service is pointed at. */ 21 | this.target = defaultArgs[0]; 22 | } 23 | 24 | /** Sends the information to make a request to Discord to the server. returning a promise with the response. 25 | * 26 | * @param {string} method HTTP method of the request. 27 | * @param {string} url Discord endpoint url. (e.g. channels/123) 28 | * @param {RequestOptions} options Optional parameters for this request. 29 | * @returns {Promise} 30 | */ 31 | request({ method, url, ...options }) { 32 | const message = new RequestMessage(method, url, options).proto; 33 | 34 | return new Promise((resolve, reject) => { 35 | super.request(message, (err, res) => { 36 | if (err === null) { 37 | resolve(ResponseMessage.fromProto(res)); 38 | } else { 39 | reject(err); 40 | } 41 | }); 42 | }); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/rpc/services/request/callbacks.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable callback-return */ 2 | 3 | 'use strict'; 4 | 5 | const { RequestMessage, ResponseMessage } = require('../../structures'); 6 | 7 | /** 8 | * Create callback functions for the request service. 9 | * 10 | * @param {Server} server 11 | */ 12 | module.exports = function (server) { 13 | async function request(call, callback) { 14 | try { 15 | const { method, url, options } = RequestMessage.fromProto(call.request); 16 | 17 | const res = await server.apiClient.request(method, url, options); 18 | 19 | callback( 20 | null, 21 | new ResponseMessage(res.status, res.statusText, res.data).proto, 22 | ); 23 | } catch (err) { 24 | if (err.response) { 25 | callback(err); 26 | } else { 27 | callback(err); 28 | } 29 | } 30 | } 31 | 32 | return { request }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/rpc/structures/identityLock/Lock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const StatusMessage = require('./StatusMessage'); 4 | const Utils = require('../../../utils'); 5 | const { LOG_SOURCES, LOG_LEVELS } = require('../../../utils/constants'); 6 | 7 | /** 8 | * A mutex primarily used by gateway clients to coordinate identifies. 9 | * Grants a token to clients that acquire the lock that will allow that 10 | * client to perform further operations on it (e,g. release the lock or 11 | * refresh the timeout). 12 | */ 13 | module.exports = class Lock { 14 | /** 15 | * Creates a new lock. 16 | * 17 | * @param {import("events").EventEmitter} emitter Emitter for log events. 18 | */ 19 | constructor(emitter) { 20 | /** 21 | * @type {string|void} A unique ID given to the client who currently has the lock 22 | * `undefined` indicates that the lock is available. 23 | */ 24 | this.token; 25 | /** @type {NodeJS.Timeout} The timeout that will unlock the lock after a time specified by the client. */ 26 | this.lockTimeout; 27 | this.emitter = emitter; 28 | } 29 | 30 | /** 31 | * Attempts to acquire the lock. 32 | * 33 | * @param {number} timeOut How long in ms to wait before expiring the lock. 34 | * @param {string|void} token Unique ID given to the last client to acquire the lock. 35 | */ 36 | acquire(timeOut, token) { 37 | let success = false; 38 | let message; 39 | 40 | if (this.token === undefined) { 41 | token = Utils.uuid(); 42 | this.lock(timeOut, token); 43 | success = true; 44 | } else if (this.token === token) { 45 | this.lock(timeOut, token); 46 | success = true; 47 | } else { 48 | message = 'Already locked by a different client.'; 49 | token = undefined; 50 | } 51 | 52 | return new StatusMessage(success, message, token); 53 | } 54 | 55 | /** 56 | * Attempts to release the lock. 57 | * 58 | * @param {string} token Unique ID given to the last client to acquire the lock. 59 | */ 60 | release(token) { 61 | let success = false; 62 | let message; 63 | 64 | if (this.token === undefined) { 65 | success = true; 66 | } else if (token === undefined) { 67 | message = 'No token provided.'; 68 | } else if (this.token === token) { 69 | this.unlock(); 70 | success = true; 71 | } else { 72 | message = 'Locked by a different client.'; 73 | } 74 | 75 | return new StatusMessage(success, message); 76 | } 77 | 78 | /** 79 | * Sets lock and sets an expire timer. 80 | * 81 | * @param {number} timeOut How long in ms to wait before expiring the lock. 82 | * @param {string|void} token The token to set the lock under. 83 | */ 84 | lock(timeOut, token) { 85 | let message; 86 | if (this.lockTimeout === undefined) { 87 | message = `Lock acquired. Timeout: ${timeOut}ms. Token: ${token}`; 88 | } else { 89 | message = `Lock refreshed. Token: ${token}`; 90 | } 91 | this.emitter.emit('DEBUG', { 92 | source: LOG_SOURCES.RPC, 93 | level: LOG_LEVELS.DEBUG, 94 | message, 95 | }); 96 | 97 | clearTimeout(this.lockTimeout); 98 | this.token = token; 99 | this.lockTimeout = setTimeout(() => { 100 | this.release(token); 101 | this.emitter.emit('DEBUG', { 102 | source: LOG_SOURCES.RPC, 103 | level: LOG_LEVELS.DEBUG, 104 | message: `Lock expired after ${timeOut}ms. Token: ${token}`, 105 | }); 106 | }, timeOut); 107 | } 108 | 109 | /** Makes the lock available and clears the expire timer. */ 110 | unlock() { 111 | clearTimeout(this.lockTimeout); 112 | this.lockTimeout = undefined; 113 | this.token = undefined; 114 | } 115 | }; 116 | -------------------------------------------------------------------------------- /src/rpc/structures/identityLock/LockRequestMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** A class for the LockRequestMessage protobuf. */ 4 | module.exports = class LockRequestMessage { 5 | /** 6 | * Creates a new LockRequestMessage sent from client to server. 7 | * 8 | * @param {number} timeOut How long in ms the server should wait before expiring the lock. 9 | * @param {string|void} token Unique ID given to the last client to acquire the lock. 10 | */ 11 | constructor(timeOut, token) { 12 | /** @type {number} How long in ms the server should wait before expiring the lock. */ 13 | this.timeOut = timeOut; 14 | /** @type {string|void} Unique ID given to the last client that acquired the lock. */ 15 | this.token = token; 16 | } 17 | 18 | /** @type {LockRequestProto} The properties of this message formatted for sending over rpc. */ 19 | get proto() { 20 | LockRequestMessage.validateOutgoing(this); 21 | 22 | return { time_out: this.timeOut, token: this.token }; 23 | } 24 | 25 | /** 26 | * Verifies that the message being sent is valid. 27 | * 28 | * @param {LockRequestMessage} lockRequest 29 | */ 30 | static validateOutgoing(lockRequest) { 31 | if (lockRequest.timeOut === undefined) { 32 | throw Error("'timeOut' must be a defined number"); 33 | } 34 | if (typeof lockRequest.timeOut !== 'number') { 35 | throw Error("'timeOut' must be type 'number'"); 36 | } 37 | if (lockRequest.token !== undefined && typeof lockRequest.token !== 'string') { 38 | throw Error("'token' must be type 'string'"); 39 | } 40 | } 41 | 42 | /** 43 | * Validates that the message being received is valid. 44 | * 45 | * @param {LockRequestProto} message 46 | */ 47 | static validateIncoming(message) { 48 | if (message.time_out === undefined) { 49 | throw Error("received invalid message. missing property 'time_out'"); 50 | } 51 | } 52 | 53 | /** 54 | * Translates the rpc message into an instance of this class. 55 | * 56 | * @param {LockRequestProto} message 57 | * @return {LockRequestMessage} 58 | */ 59 | static fromProto(message) { 60 | LockRequestMessage.validateIncoming(message); 61 | 62 | return new LockRequestMessage(message.time_out, message.token); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/rpc/structures/identityLock/StatusMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // const TokenMessage = require("./TokenMessage"); 4 | 5 | /** A class for the StatusMessage protobuf. */ 6 | module.exports = class StatusMessage { 7 | /** 8 | * Creates a new StatusMessage sent from server to client. 9 | * 10 | * @param {boolean} didSucceed Whether or not the operation was successful. 11 | * @param {string|void} message Reason why the operation failed. 12 | * @param {string|void} token Unique ID given to the last client to acquire the lock. 13 | */ 14 | constructor(didSucceed, message, token) { 15 | this.success = didSucceed; 16 | this.message = message; 17 | this.token = token; 18 | // if (token !== undefined) { 19 | // this.token = new TokenMessage(token); 20 | // } 21 | } 22 | 23 | /** @type {StatusProto} The properties of this message formatted for sending over rpc. */ 24 | get proto() { 25 | StatusMessage.validateOutgoing(this); 26 | 27 | return { 28 | success: this.success, 29 | message: this.message, 30 | token: this.token, 31 | }; 32 | } 33 | 34 | /** 35 | * Verifies that the message being sent is valid. 36 | * 37 | * @param {StatusMessage} status 38 | */ 39 | static validateOutgoing(status) { 40 | if (typeof status.success !== 'boolean') { 41 | throw Error("'success' must be type 'boolean'"); 42 | } 43 | if (status.success === false && !status.message) { 44 | throw Error("a message must be provided when 'success' is false"); 45 | } 46 | } 47 | 48 | /** 49 | * Validates that the message being received is valid. 50 | * 51 | * @param {StatusProto} message 52 | */ 53 | static validateIncoming(message) { 54 | if (message.success === undefined) { 55 | throw Error("received invalid message. missing property 'success'"); 56 | } 57 | } 58 | 59 | /** 60 | * Validate incoming message and translate it into common state. 61 | * 62 | * @param {StatusProto} message 63 | * @returns {StatusMessage} 64 | */ 65 | static fromProto(message) { 66 | this.validateIncoming(message); 67 | 68 | return new StatusMessage( 69 | message.success, 70 | message.message, 71 | message.token, 72 | // message.token ? TokenMessage.fromProto(message.token).value : undefined 73 | ); 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /src/rpc/structures/identityLock/TokenMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** A class for the TokenMessage protobuf. */ 4 | module.exports = class TokenMessage { 5 | /** 6 | * Create a new TokenMessage sent from the client to server. 7 | * 8 | * @param {string} value The unique ID given to the last client to acquire the lock. 9 | */ 10 | constructor(value) { 11 | this.value = value; 12 | } 13 | 14 | /** @type {TokenProto} The properties of this message formatted for sending over rpc. */ 15 | get proto() { 16 | TokenMessage.validateOutgoing(this); 17 | 18 | return { value: this.value }; 19 | } 20 | 21 | /** 22 | * Verifies that the message being sent is valid. 23 | * 24 | * @param {TokenMessage} token 25 | */ 26 | static validateOutgoing(token) { 27 | if (token.value === undefined) { 28 | throw Error("'value' must be a defined string"); 29 | } 30 | if (typeof token.value !== 'string') { 31 | throw Error("'value' must be type 'string'"); 32 | } 33 | } 34 | 35 | /** 36 | * Validates that the message being received is valid. 37 | * 38 | * @param {TokenProto} message 39 | */ 40 | static validateIncoming(message) { 41 | if (message.value === undefined) { 42 | throw Error("received invalid message. missing property 'value'"); 43 | } 44 | } 45 | 46 | /** 47 | * Validate incoming message and translate it into common state. 48 | * 49 | * @param {TokenProto} message 50 | * @returns {TokenMessage} 51 | */ 52 | static fromProto(message) { 53 | TokenMessage.validateIncoming(message); 54 | 55 | return new TokenMessage(message.value); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/rpc/structures/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | /* Identify Lock */ 5 | Lock: require('./identityLock/Lock'), 6 | LockRequestMessage: require('./identityLock/LockRequestMessage'), 7 | /* Request */ 8 | TokenMessage: require('./identityLock/TokenMessage'), 9 | StatusMessage: require('./identityLock/StatusMessage'), 10 | RequestMessage: require('./request/RequestMessage'), 11 | ResponseMessage: require('./request/ResponseMessage'), 12 | /* Rate Limit */ 13 | RequestMetaMessage: require('./rateLimit/RequestMetaMessage'), 14 | AuthorizationMessage: require('./rateLimit/AuthorizationMessage'), 15 | RateLimitStateMessage: require('./rateLimit/RateLimitStateMessage'), 16 | }; 17 | -------------------------------------------------------------------------------- /src/rpc/structures/rateLimit/AuthorizationMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** A class for the AuthorizationMessage protobuf. */ 4 | module.exports = class AuthorizationMessage { 5 | /** 6 | * Creates a new AuthorizationMessage sent from client to server. 7 | * 8 | * @param {number} resetAfter How long in ms the client should wait before attempting to authorize this request. 9 | */ 10 | constructor(resetAfter) { 11 | /** @type {number} How long in ms the client should wait before attempting to authorize this request. */ 12 | this.resetAfter = resetAfter; 13 | } 14 | 15 | /** @type {AuthorizationProto} The properties of this message formatted for sending over rpc. */ 16 | get proto() { 17 | // AuthorizationMessage.validateOutgoing(this); 18 | 19 | return { reset_after: this.resetAfter }; 20 | } 21 | 22 | /** 23 | * Verifies that the message being sent is valid. 24 | * 25 | * @param {AuthorizationMessage} requestMeta 26 | */ 27 | static validateOutgoing(authorization) { 28 | if (authorization.resetAfter === undefined) { 29 | throw Error("'resetAfter' must be a defined number"); 30 | } 31 | } 32 | 33 | /** 34 | * Validates that the message being received is valid. 35 | * 36 | * @param {AuthorizationProto} message 37 | */ 38 | static validateIncoming(message) { 39 | if (message.reset_after === undefined) { 40 | throw Error("received invalid message. missing property 'reset_after'"); 41 | } 42 | } 43 | 44 | /** 45 | * Translates the rpc message into an instance of this class. 46 | * 47 | * @param {AuthorizationProto} message 48 | * @return {AuthorizationMessage} 49 | */ 50 | static fromProto(message) { 51 | AuthorizationMessage.validateIncoming(message); 52 | 53 | return new AuthorizationMessage(message.reset_after); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/rpc/structures/rateLimit/RateLimitStateMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const RequestMetaMessage = require('./RequestMetaMessage'); 4 | 5 | /** A class for the RateLimitStateMessage protobuf. */ 6 | module.exports = class RateLimitStateMessage { 7 | /** 8 | * Creates a new RateLimitStateMessage sent from client to server. 9 | * 10 | * @param {RequestMetaMessage} requestMeta Meta data from the requests used to identify the rate limit. 11 | * @param {boolean} global From Discord - If the request was globally rate limited. 12 | * @param {string} bucket From Discord - Id of the rate limit bucket. 13 | * @param {number} limit From Discord - Number of requests that can be made between rate limit triggers. 14 | * @param {number} remaining From Discord - Number of requests available before hitting rate limit. 15 | * @param {number} resetAfter From Discord - How long in ms the rate limit resets. 16 | */ 17 | constructor(requestMeta, global, bucket, limit, remaining, resetAfter) { 18 | /** @type {RequestMetaMessage} Meta data from the requests used to identify the rate limit. */ 19 | this.requestMeta = requestMeta; 20 | /** @type {boolean} From Discord - If the request was globally rate limited. */ 21 | this.global = global || false; 22 | /** @type {string} bucket From Discord - Id of the rate limit bucket. */ 23 | this.bucket = bucket; 24 | /** @type {number} limit From Discord - Number of requests that can be made between rate limit triggers. */ 25 | this.limit = limit; 26 | /** @type {number} remaining From Discord - Number of requests available before hitting rate limit. */ 27 | this.remaining = remaining; 28 | /** @type {number} resetAfter From Discord - How long in ms the rate limit resets. */ 29 | this.resetAfter = resetAfter; 30 | } 31 | 32 | /** @type {RateLimitStateProto} The properties of this message formatted for sending over rpc. */ 33 | get proto() { 34 | RateLimitStateMessage.validateOutgoing(this); 35 | 36 | return { 37 | request_meta: this.requestMeta.proto, 38 | bucket: this.bucket, 39 | limit: this.limit, 40 | remaining: this.remaining, 41 | reset_after: this.resetAfter, 42 | global: this.global, 43 | }; 44 | } 45 | 46 | /** 47 | * Verifies that the message being sent is valid. 48 | * 49 | * @param {RateLimitStateMessage} requestMeta 50 | */ 51 | static validateOutgoing(rateLimitState) { 52 | const { 53 | requestMeta, 54 | global, 55 | bucket, 56 | remaining, 57 | resetAfter, 58 | limit, 59 | } = rateLimitState; 60 | if ( 61 | requestMeta === undefined 62 | || !(requestMeta instanceof RequestMetaMessage) 63 | ) { 64 | throw Error("'requestMeta' must be a defined RequestMetaMessage"); 65 | } 66 | if (global === undefined) { 67 | throw Error("'global' must be a defined boolean if bucket is defined"); 68 | } 69 | 70 | if (bucket !== undefined) { 71 | if (remaining === undefined) { 72 | throw Error( 73 | "'remaining' must be a defined number if bucket is defined", 74 | ); 75 | } 76 | if (resetAfter === undefined) { 77 | throw Error( 78 | "'resetAfter' must be a defined number if bucket is defined", 79 | ); 80 | } 81 | if (limit === undefined) { 82 | throw Error("'limit' must be a defined number if bucket is defined"); 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * Validates that the message being received is valid. 89 | * 90 | * @param {RateLimitStateProto} message 91 | */ 92 | static validateIncoming(message) { 93 | if (message.request_meta === undefined) { 94 | throw Error("received invalid message. missing property 'request_meta'"); 95 | } 96 | if (message.global === undefined) { 97 | throw Error("received invalid message. missing property 'global'"); 98 | } 99 | 100 | if (message.bucket !== undefined) { 101 | if (message.remaining === undefined) { 102 | throw Error("received invalid message. missing property 'remaining'"); 103 | } 104 | if (message.reset_after === undefined) { 105 | throw Error("received invalid message. missing property 'reset_after'"); 106 | } 107 | if (message.limit === undefined) { 108 | throw Error("received invalid message. missing property 'limit'"); 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Translates the rpc message into an instance of this class. 115 | * 116 | * @param {RateLimitStateProto} message 117 | * @return {RateLimitStateMessage} 118 | */ 119 | static fromProto(message) { 120 | RateLimitStateMessage.validateIncoming(message); 121 | 122 | return new RateLimitStateMessage( 123 | RequestMetaMessage.fromProto(message.request_meta), 124 | message.global, 125 | message.bucket, 126 | message.limit, 127 | message.remaining, 128 | message.reset_after, 129 | ); 130 | } 131 | }; 132 | -------------------------------------------------------------------------------- /src/rpc/structures/rateLimit/RequestMetaMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** A class for the RequestMetaMessage protobuf. */ 4 | module.exports = class RequestMetaMessage { 5 | /** 6 | * Creates a new RequestMetaMessage sent from client to server. 7 | * 8 | * @param {string} method HTTP method of the request. 9 | * @param {string|void} url Discord endpoint url. (e.g. channels/123) 10 | */ 11 | constructor(method, url) { 12 | /** @type {string} HTTP method of the request. */ 13 | this.method = method; 14 | /** @type {string} Discord endpoint url. (e.g. channels/123) */ 15 | this.url = url; 16 | } 17 | 18 | /** @type {RequestMetaProto} The properties of this message formatted for sending over rpc. */ 19 | get proto() { 20 | RequestMetaMessage.validateOutgoing(this); 21 | 22 | return { method: this.method, url: this.url }; 23 | } 24 | 25 | /** 26 | * Verifies that the message being sent is valid. 27 | * 28 | * @param {RequestMetaMessage} requestMeta 29 | */ 30 | static validateOutgoing(requestMeta) { 31 | if (requestMeta.method === undefined) { 32 | throw Error("'method' must be a defined string"); 33 | } 34 | if (requestMeta.url === undefined) { 35 | throw Error("'url' must be a defined string"); 36 | } 37 | } 38 | 39 | /** 40 | * Validates that the message being received is valid. 41 | * 42 | * @param {RequestMetaProto} message 43 | */ 44 | static validateIncoming(message) { 45 | if (message.method === undefined) { 46 | throw Error("received invalid message. missing property 'method'"); 47 | } 48 | if (message.url === undefined) { 49 | throw Error("received invalid message. missing property 'url'"); 50 | } 51 | } 52 | 53 | /** 54 | * Translates the rpc message into an instance of this class. 55 | * 56 | * @param {RequestMetaProto} message 57 | * @return {RequestMetaMessage} 58 | */ 59 | static fromProto(message) { 60 | RequestMetaMessage.validateIncoming(message); 61 | 62 | return new RequestMetaMessage(message.method, message.url); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/rpc/structures/request/RequestMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** A class for the RequestMessage protobuf */ 4 | module.exports = class RequestMessage { 5 | /** 6 | * Create a new RequestMessage sent from client to server. 7 | * 8 | * @param {string} method HTTP method of the request. 9 | * @param {string} url Discord endpoint url. (e.g. channels/123) 10 | * @param {RequestOptions} options Optional parameters for this request. 11 | */ 12 | constructor(method, url, options = {}) { 13 | /** @type {string} HTTP method of the request. */ 14 | this.method = method; 15 | /** @type {string} Discord REST endpoint target of the request. (e.g. channels/123) */ 16 | this.url = url; 17 | /** @type {*} Data to send in the body of the request. */ 18 | this.data = options.data; 19 | /** @type {Object} Headers to send with the request. */ 20 | this.headers = options.headers; 21 | } 22 | 23 | /** @type {RequestProto} The properties of this message formatted for sending over rpc. */ 24 | get proto() { 25 | const proto = { 26 | method: this.method, 27 | url: this.url, 28 | }; 29 | 30 | if (this.data !== undefined) { 31 | proto.data = JSON.stringify(this.data); 32 | } 33 | 34 | if (this.headers !== undefined) { 35 | proto.headers = JSON.stringify(this.headers); 36 | } 37 | 38 | RequestMessage.validateOutgoing(proto); 39 | 40 | return proto; 41 | } 42 | 43 | /** 44 | * Verifies that the message being sent is valid. 45 | * 46 | * @param {RequestMessage} token 47 | */ 48 | static validateOutgoing(request) { 49 | if (typeof request.method !== 'string') { 50 | throw Error("'method' must be type 'string'"); 51 | } 52 | if (typeof request.url !== 'string') { 53 | throw Error("'url' must be type 'string'"); 54 | } 55 | if ( 56 | request.time_out !== undefined 57 | && typeof request.time_out !== 'number' 58 | ) { 59 | throw Error("'time_out' must be type 'number'"); 60 | } 61 | } 62 | 63 | /** 64 | * Validates that the message being received is valid. 65 | * 66 | * @param {RequestProto} message 67 | */ 68 | static validateIncoming(message) { 69 | if (message.method === undefined) { 70 | throw Error("received invalid message. missing property 'method'"); 71 | } 72 | if (message.url === undefined) { 73 | throw Error("received invalid message. missing property 'url'"); 74 | } 75 | } 76 | 77 | /** 78 | * Validate incoming message and translate it into common state. 79 | * 80 | * @param {RequestProto} message 81 | * @returns {RequestMessage} 82 | */ 83 | static fromProto(message) { 84 | RequestMessage.validateIncoming(message); 85 | 86 | const { method, url, ...options } = message; 87 | 88 | if (options.data) { 89 | options.data = JSON.parse(options.data); 90 | } 91 | if (options.headers) { 92 | options.headers = JSON.parse(options.headers); 93 | } 94 | 95 | return { method, url, options }; 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /src/rpc/structures/request/ResponseMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** A class for the ResponseMessage protobuf */ 4 | module.exports = class ResponseMessage { 5 | /** 6 | * Creates a new ResponseMessage send from server to client. 7 | * 8 | * @param {number} status The HTTP status code of the response. 9 | * @param {string} statusText Status message returned by the server. (e.g. "OK" with a 200 status) 10 | * @param {*} data The data returned by Discord. 11 | */ 12 | constructor(status, statusText, data) { 13 | this.status = status; 14 | this.statusText = statusText; 15 | this.data = data; 16 | } 17 | 18 | /** @type {ResponseProto} The properties of this message formatted for sending over rpc. */ 19 | get proto() { 20 | return { 21 | status_code: this.status, 22 | status_text: this.statusText, 23 | data: this.data, 24 | }; 25 | } 26 | 27 | /** 28 | * Validates that the message being received is valid. 29 | * 30 | * @param {ResponseProto} message 31 | */ 32 | static validateIncoming(response) { 33 | if (response.status_code === undefined) { 34 | throw Error("received invalid message. missing property 'status_code'"); 35 | } 36 | } 37 | 38 | /** 39 | * Validate incoming message and translate it into common state. 40 | * 41 | * @param {ResponseProto} message 42 | * @returns {ApiResponse} 43 | */ 44 | static fromProto(message) { 45 | ResponseMessage.validateIncoming(message); 46 | 47 | const { status_code: status, status_text: statusText, data } = message; 48 | 49 | return { 50 | status, 51 | statusText, 52 | data: data.startsWith('{') ? JSON.parse(data) : data, 53 | }; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/typedefs.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import("./clients/Api")} Api */ 2 | 3 | /** @typedef {import("./clients/Gateway")} Gateway */ 4 | 5 | /** @typedef {import("./clients/Paracord")} Paracord */ 6 | 7 | /** @typedef {import("./rpc/services/request/RequestService")} RequestService */ 8 | 9 | /** @typedef {import("./rpc/services/identifyLock/IdentifyLockService")} IdentifyLockService */ 10 | 11 | /** @typedef {import("./clients/Api/structures/RateLimit")} RateLimit */ 12 | 13 | /** @typedef {import("./clients/Api/structures/RateLimitCache")} RateLimitCache */ 14 | 15 | /** @typedef {import("./clients/Api/structures/RateLimitMap")} RateLimitMap */ 16 | 17 | /** @typedef {import("./clients/Api/structures/Request")} Request */ 18 | 19 | /** @typedef {import("./clients/Api/structures/RequestQueue")} RequestQueue */ 20 | 21 | /** @typedef {import("./clients/Paracord/structures/Guild")} Guild */ 22 | 23 | /** @typedef {import("./rpc/structures/identityLock/Lock")} Lock */ 24 | 25 | /** @typedef {import("./rpc/server/Server")} Server */ 26 | 27 | /** @typedef {import("./clients/Api/structures/BaseRequest"} BaseRequest */ 28 | 29 | /** @typedef {import("./rpc/structures/rateLimit/RequestMetaMessage")} RequestMetaMessage */ 30 | 31 | /** @typedef {import("./clients/Api/structures/RateLimitHeaders")} RateLimitHeaders */ 32 | 33 | // API 34 | 35 | /** 36 | * @typedef ApiOptions Optional parameters for this api handler. 37 | * 38 | * @property {import("events").EventEmitter} [emitter] Event emitter through which to emit debug and warning events. 39 | */ 40 | 41 | /** 42 | * @typedef RateLimitState The known state of a rate limit. 43 | * @property {number} remaining Number of requests available before hitting rate limit. 44 | * @property {number} limit From Discord - rate limit request cap. 45 | * @property {number|void} resetTimestamp When the rate limit requests remaining rests to `limit`. 46 | */ 47 | 48 | /** 49 | * @typedef ResponseHeaders Rate limit state with the bucket id. TODO(lando): add docs link 50 | * @property {string} bucket From Discord - the id of the rate limit bucket. 51 | * @property {RateLimitState} state 52 | */ 53 | 54 | /** 55 | * @callback WrappedRequest A `request` method of an axios instance 56 | * wrapped to decrement the associated rate limit cached state if one exists. 57 | * 58 | * @param {Request} request Data for axios' request method. 59 | * @returns {*} Response from Discord. 60 | */ 61 | 62 | /** 63 | * @typedef RequestOptions Optional parameters for a Discord REST request. 64 | * 65 | * @property {*} data Data to send in the body of the request. 66 | * @property {Object} headers Headers to send with the request. 67 | */ 68 | 69 | // Gateway 70 | 71 | /** 72 | * @typedef GatewayOptions Optional parameters for this gateway handler. 73 | * 74 | * @property {Object} [identity] An object containing information for identifying with the gateway. `shard` property will be overwritten when using Paracord's Shard Launcher. https://discordapp.com/developers/docs/topics/gateway#identify-identify-structure 75 | * @property {import("events").EventEmitter} [emitter] Emitter through which Discord gateway events are sent. 76 | * @property {Object} [events] Key:Value mapping DISCORD_EVENT to user's preferred emitted name. 77 | * @property {RequestService|Api} [api] 78 | */ 79 | 80 | /** @typedef {import("./clients/Gateway/structures/Identity")} Identity */ 81 | 82 | /** @typedef {[number, number]} Shard [ShardID, ShardCount] to identify with. https://discordapp.com/developers/docs/topics/gateway#identify-identify-structure */ 83 | 84 | /** 85 | * @typedef GatewayLockServerOptions 86 | * 87 | * @property {void|ServerOptions} mainServerOptions Options for connecting this service to the identifylock server. Will not be released except by time out. Best used for global minimum wait time. Pass `null` to ignore. 88 | * @property {ServerOptions} [serverOptions] Options for connecting this service to the identifylock server. Will be acquired and released in order. 89 | */ 90 | 91 | // Paracord 92 | 93 | /** 94 | * @typedef ParacordOptions Optional parameters for this Paracord client. 95 | * 96 | * @property {Object} [events] Key:Value mapping DISCORD_EVENT to user's preferred emitted name. 97 | * @property {ApiOptions} [apiOptions] 98 | * @property {GatewayOptions} [gatewayOptions] 99 | */ 100 | 101 | /** 102 | * @typedef ParacordLoginOptions Optional parameters for Paracord's login method. 103 | * 104 | * @param {Object} [identity] An object containing information for identifying with the gateway. https://discordapp.com/developers/docs/topics/gateway#identify-identify-structure 105 | * @property {number[]} [shards] Shards to spawn internally. 106 | * @property {number} [shardCount] The total number of shards that will be handled by the bot. 107 | * @property {boolean} [allowEventsDuringStartup=false] During startup, if events should be emitted before `PARACORD_STARTUP_COMPLETE` is emitted. `GUILD_CREATE` events will never be emitted during start up. 108 | */ 109 | 110 | // Shard Launcher 111 | 112 | /** 113 | * @typedef ShardLauncherOptions 114 | * @property {string} [token] Discord token. Used to find recommended shard count when no `shardIds` provided. Will be coerced into a bot token. 115 | * @property {InternalShardIds} [shardIds] Ids of the shards to start internally. Ignored if `shardChunks` is defined. 116 | * @property {InternalShardIds[]} [shardChunks] Arrays of shard Ids to launch. Each item will spawn a pm2 process with the designated shards internally. 117 | * @property {number} [shardCount] Total number of shards this app will be running across all instances. 118 | * @property {string} [appName] Name that will appear beside the shard number in pm2. 119 | * @property {Object} [env] Additional environment variables to load into the app. 120 | */ 121 | 122 | /** @typedef {number[]} InternalShardIds Shard Ids designated to be spawned internally. */ 123 | 124 | // Misc 125 | 126 | /** 127 | * @typedef ServerOptions 128 | * 129 | * @property {string} [host] 130 | * @property {number|string} [port] 131 | */ 132 | 133 | /** 134 | * @typedef ApiResponse Subset of response data after making a request with the Api handler. 135 | * 136 | * @property {number} status The HTTP status code of the response. 137 | * @property {string} statusText Status message returned by the server. (e.g. "OK" with a 200 status) 138 | * @property {*} data The data returned by Discord. 139 | */ 140 | 141 | /** 142 | * @typedef RpcServerOptions 143 | * 144 | * @property {string} [host] 145 | * @property {string|number} [port] 146 | * @property {import("events").EventEmitter} [emitter] 147 | * @property {RpcServerBindArgs} [bindArgs] 148 | */ 149 | 150 | /** 151 | * @typedef RpcServerBindArgs 152 | * 153 | * @property {string|number} port 154 | * @property {import("@grpc/grpc-js").ServerCredentials} creds 155 | * @property {Function} callback 156 | */ 157 | 158 | /** 159 | * @typedef ServiceOptions 160 | * 161 | * @property {string} [host] 162 | * @property {string|number} [port] 163 | * @property {import("@grpc/grpc-js").ChannelCredentials} [channel] 164 | */ 165 | 166 | /** 167 | * @typedef StatusMessage 168 | * 169 | * @property {boolean} success true, the operation was a success; false, the operation failed. 170 | * @property {string} message Reason for the failed operation. 171 | * @property {string} token Unique ID given to the last client to acquire the lock. 172 | */ 173 | 174 | /** 175 | * @typedef LockRequestProto 176 | * 177 | * @property {number} time_out How long in ms the server should wait before expiring the lock. 178 | * @property {string} token Unique ID given to the last client to acquire the lock. 179 | */ 180 | 181 | /** 182 | * @typedef TokenProto 183 | * 184 | * @property {string} value The string value of the token. 185 | */ 186 | 187 | /** 188 | * @typedef StatusProto 189 | * 190 | * @property {boolean} success Whether or not the operation was successful. 191 | * @property {string|void} [message] Reason why the operation failed. 192 | * @property {string|void} token Unique ID given to the last client to acquire the lock. 193 | */ 194 | 195 | /** 196 | * @typedef RequestProto 197 | * 198 | * @property {string} method HTTP method of the request. 199 | * @property {string} url Discord endpoint url. (e.g. channels/123) 200 | * @property {string} [data] JSON encoded data to send in the body of the request. 201 | * @property {string} [headers] JSON encoded headers to send with the request. 202 | */ 203 | 204 | /** 205 | * @typedef ResponseProto 206 | * 207 | * @property {string} status_code The HTTP status code of the response. 208 | * @property {string} status_text Status message returned by the server. (e.g. "OK" with a 200 status) 209 | * @property {string} data JSON encoded data returned by Discord. 210 | */ 211 | 212 | /** 213 | * @typedef RequestMetaProto 214 | * 215 | * @property {string} method HTTP method of the request. 216 | * @property {string} url Discord endpoint url. (e.g. channels/123) 217 | */ 218 | 219 | /** 220 | * @typedef AuthorizationProto 221 | * 222 | * @property {number} reset_after How long the client should wait in ms before asking to authorize the request again, if at all. 223 | */ 224 | 225 | /** 226 | * @typedef RateLimitStateProto 227 | * 228 | * @property {string} bucket From Discord - the id of the rate limit bucket. 229 | * @property {number} remaining From Discord - number of requests available before hitting rate limit. 230 | * @property {number} limit From Discord - rate limit request cap. 231 | * @property {number|void} reset_timestamp When the rate limit requests remaining rests to `limit`. 232 | */ 233 | -------------------------------------------------------------------------------- /src/utils/Utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { DISCORD_EPOCH, PERMISSIONS: P, DISCORD_CDN_URL } = require('./constants'); 4 | 5 | /** A class of help functions used throughout the library. */ 6 | module.exports = class Util { 7 | /** 8 | * Returns a new object that is a clone of the original. 9 | * 10 | * @param {Object} object Object to clone. 11 | */ 12 | static clone(object) { 13 | return JSON.parse(JSON.stringify(object)); 14 | } 15 | 16 | /** 17 | * Returns a timestamp of some time in the future. 18 | * 19 | * @param {number} seconds Number of seconds from now to base the timestamp on. 20 | */ 21 | static timestampNSecondsInFuture(seconds) { 22 | return new Date().getTime() + Number(seconds) * 1000; 23 | } 24 | 25 | /** 26 | * Returns a timestamp of some time in the future. 27 | * 28 | * @param {number} milliseconds Number of milliseconds from now to base the timestamp on. 29 | */ 30 | static timestampNMillisecondsInFuture(milliseconds) { 31 | return new Date().getTime() + Number(milliseconds); 32 | } 33 | 34 | static millisecondsFromNow(timestamp) { 35 | return Number(timestamp) - new Date().getTime(); 36 | } 37 | 38 | // static guildShardFromID(guildId, shardCount) { 39 | // /* eslint-disable-next-line radix */ 40 | // return parseInt(BigInt(guildId) >> BigInt(22)) % shardCount; 41 | // } 42 | 43 | /** 44 | * Extract a timestamp from a Discord snowflake. 45 | * 46 | * @param {string} snowflake Discord snowflake. 47 | */ 48 | static timestampFromSnowflake(snowflake) { 49 | // eslint-disable-next-line no-undef 50 | const bits = BigInt(snowflake) 51 | .toString(2) 52 | .padStart(64, '0'); 53 | 54 | return parseInt(bits.substring(0, 42), 2) + DISCORD_EPOCH; 55 | } 56 | 57 | /** 58 | * This is a bot library. Coerced non-compliant tokens to be bot-like. 59 | * 60 | * @param {string} token Discord token. 61 | */ 62 | static coerceTokenToBotLike(token) { 63 | if (!token.startsWith('Bot ')) return `Bot ${token}`; 64 | return token; 65 | } 66 | 67 | /** 68 | * Compute a member's channel-level permissions. 69 | * 70 | * @param {Object} member Member whose perms to check. 71 | * @param {Guild} guild Guild in which to check the member's permissions. 72 | * @param {Object} channel Channel in which to check the member's permissions. 73 | * @param {boolean} [stopOnOwnerAdmin] Whether or not to stop and return the Administrator perm if the user qualifies. 74 | * @returns {number} THe Administrator perm or the new perms. 75 | */ 76 | static computeChannelPerms(member, guild, channel, stopOnOwnerAdmin = false) { 77 | const guildPerms = this.computeGuildPerms(member, guild, stopOnOwnerAdmin); 78 | 79 | if (stopOnOwnerAdmin && guildPerms & P.ADMINISTRATOR) { 80 | return P.ADMINISTRATOR; 81 | } 82 | 83 | return this.computeChannelOverwrites(guildPerms, member, guild, channel); 84 | } 85 | 86 | /** 87 | * Compute a member's guild-level permissions. 88 | * 89 | * @param {Object} member Member whose perms to check. 90 | * @param {Guild} guild Guild in which to check the member's permissions. 91 | * @param {boolean} [stopOnOwnerAdmin] Whether or not to stop and return the Administrator perm if the user qualifies. 92 | * @returns {number} THe Administrator perm or the new perms. 93 | */ 94 | static computeGuildPerms(member, guild, stopOnOwnerAdmin = false) { 95 | if (stopOnOwnerAdmin && guild.owner_id === member.user.id) { 96 | return P.ADMINISTRATOR; 97 | } 98 | 99 | const { roles } = guild; 100 | 101 | // start with @everyone perms 102 | let perms = roles.get(guild.id).permissions; 103 | 104 | for (const id of member.roles) { 105 | const role = roles.get(id); 106 | if (role !== undefined) { 107 | if ((role.permissions & P.ADMINISTRATOR) !== 0) { 108 | return P.ADMINISTRATOR; 109 | } 110 | 111 | perms |= role.permissions; 112 | } 113 | } 114 | 115 | return perms; 116 | } 117 | 118 | /** 119 | * Compute the channel's overriding permissions against the member's channel-level permissions. 120 | * 121 | * @param {number} perms Member's channel-level permissions. 122 | * @param {Object} member Member whose perms to check. 123 | * @param {Guild} guild Guild in which to check the member's permissions. 124 | * @param {Object} channel Channel in which to check the member's permissions. 125 | * @returns {number} The new perms. 126 | */ 127 | static computeChannelOverwrites(perms, member, guild, channel) { 128 | const { permission_overwrites: overwrites } = channel; 129 | 130 | perms = this._everyoneOverwrites(perms, overwrites, guild.id); 131 | perms = this._roleOverwrites(perms, overwrites, member.roles); 132 | perms = this._memberOverwrites(perms, overwrites, member.user.id); 133 | 134 | return perms; 135 | } 136 | 137 | /** 138 | * When computing channel overwrites, applies the "@everyone" overwrite. 139 | * @private 140 | * 141 | * @param {number} perms Member's channel-level permissions. 142 | * @param {Object[]} overwrites Channel's overwrites. 143 | * @param {string} guildId ID of the guild in which the permissions are being checked. 144 | * @returns {number} The new perms. 145 | */ 146 | static _everyoneOverwrites(perms, overwrites, guildId) { 147 | for (const o of overwrites) { 148 | if (o.type === 'role' && o.id === guildId) { 149 | perms |= o.allow; 150 | perms &= ~o.deny; 151 | break; 152 | } 153 | } 154 | return perms; 155 | } 156 | 157 | /** 158 | * When computing channel overwrites, applies the role overwrites. 159 | * @private 160 | * 161 | * @param {number} perms Member's channel-level permissions. 162 | * @param {Object[]} overwrites Channel's overwrites. 163 | * @param {Map} roles Roles in the guild in which the permissions are being checked. 164 | * @returns {number} The new perms. 165 | */ 166 | static _roleOverwrites(perms, overwrites, roles) { 167 | for (const o of overwrites) { 168 | if (o.type === 'role' && roles.includes(o.id)) { 169 | perms |= o.allow; 170 | perms &= ~o.deny; 171 | } 172 | } 173 | 174 | return perms; 175 | } 176 | 177 | /** 178 | * When computing channel overwrites, applies the member overwrites. 179 | * @private 180 | * 181 | * @param {number} perms Member's channel-level permissions. 182 | * @param {Object[]} overwrites Channel's overwrites. 183 | * @param {string} memberId ID of the member whose permissions are being checked. 184 | * @returns {number} The new perms. 185 | */ 186 | static _memberOverwrites(perms, overwrites, memberId) { 187 | for (const o of overwrites) { 188 | if (o.type === 'member' && o.id === memberId) { 189 | perms |= o.allow; 190 | perms &= ~o.deny; 191 | break; 192 | } 193 | } 194 | return perms; 195 | } 196 | 197 | static constructUserAvatarUrl(user) { 198 | if (user.avatar === null || user.avatar === undefined) { 199 | return `${DISCORD_CDN_URL}embed/avatars/${Number(user.discriminator) 200 | % 5}.png`; 201 | } 202 | 203 | if (user.avatar.startsWith('a_')) { 204 | return `${DISCORD_CDN_URL}avatars/${user.id}/${user.avatar}.gif`; 205 | } 206 | 207 | return `${DISCORD_CDN_URL}avatars/${user.id}/${user.avatar}.png`; 208 | } 209 | 210 | /** 211 | * Appends a user's username to their discriminator in a common format. 212 | * 213 | * @param {Object} user 214 | */ 215 | static constructUserTag(user) { 216 | return `${user.username}#${user.discriminator}`; 217 | } 218 | 219 | /** 220 | * Assigns functions to an object and binds that object to their `this`. 221 | * 222 | * @param {Object} obj Object to bind to functions and assign functions those functions as properties. 223 | * @param {Object} funcs Functions to assign to object. 224 | */ 225 | static bindFunctionsFromFile(obj, funcs) { 226 | for (const prop of Object.getOwnPropertyNames(funcs)) { 227 | if (typeof funcs[prop] === 'function') { 228 | obj[prop] = funcs[prop].bind(obj); 229 | } 230 | } 231 | } 232 | 233 | /** 234 | * Generates a unique Id. 235 | * 236 | * https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript 237 | * @returns {string} 238 | */ 239 | static uuid() { 240 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 241 | const r = (Math.random() * 16) | 0; 242 | /* eslint-disable-next-line eqeqeq */ 243 | const v = c == 'x' ? r : (r & 0x3) | 0x8; 244 | return v.toString(16); 245 | }); 246 | } 247 | 248 | /** 249 | * Assigns to a Discord object's the timestamp of when it was created. 250 | * 251 | * @param {Object} obj Discord object with a snowflake ID. 252 | */ 253 | static assignCreatedOn(obj) { 254 | if (obj.created_on === undefined) { 255 | obj.created_on = this.timestampFromSnowflake(obj.id); 256 | } 257 | } 258 | }; 259 | -------------------------------------------------------------------------------- /src/utils/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SECOND_IN_MILLISECONDS = 1e3; 4 | const MINUTE_IN_MILLISECONDS = 60 * SECOND_IN_MILLISECONDS; 5 | 6 | module.exports = { 7 | SECOND_IN_MILLISECONDS, 8 | MINUTE_IN_MILLISECONDS, 9 | GIGABYTE_IN_BYTES: 1073741824, 10 | /** Websocket parameters appended to the url received from Discord. */ 11 | GATEWAY_DEFAULT_WS_PARAMS: '?v=6&encoding=json', 12 | /** Gateway websocket connection rate limit. */ 13 | GATEWAY_MAX_REQUESTS_PER_MINUTE: 120, 14 | /** A buffer the reserves this amount of gateway requests every minute for critical tasks. */ 15 | GATEWAY_REQUEST_BUFFER: 4, 16 | /** https://discordapp.com/developers/docs/topics/opcodes-and-status-codes */ 17 | GATEWAY_OP_CODES: { 18 | DISPATCH: 0, 19 | HEARTBEAT: 1, 20 | IDENTIFY: 2, 21 | RESUME: 6, 22 | RECONNECT: 7, 23 | REQUEST_GUILD_MEMBERS: 8, 24 | INVALID_SESSION: 9, 25 | HELLO: 10, 26 | HEARTBEAT_ACK: 11, 27 | }, 28 | /** https://discordapp.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes */ 29 | GATEWAY_CLOSE_CODES: { 30 | CLEAN: 1000, 31 | ABNORMAL_CLOSE: 1006, 32 | UNKNOWN_ERROR: 4000, 33 | UNKNOWN_OPCODE: 4001, 34 | DECODE_ERROR: 4002, 35 | NOT_AUTHENTICATED: 4003, 36 | AUTHENTICATION_FAILED: 4004, 37 | ALREADY_AUTHENTICATED: 4005, 38 | SESSION_NO_LONGER_VALID: 4006, 39 | INVALID_SEQ: 4007, 40 | RATE_LIMITED: 4008, 41 | SESSION_TIMEOUT: 4009, 42 | INVALID_SHARD: 4010, 43 | SHARDING_REQUIRED: 4011, 44 | INVALID_VERSION: 4012, 45 | INVALID_INTENT: 4013, 46 | DISALLOWED_INTENT: 4014, 47 | HEARTBEAT_TIMEOUT: 4999, // Not a Discord close event 48 | 49 | }, 50 | CHANNEL_TYPES: { 51 | GUILD_TEXT: 0, 52 | DM: 1, 53 | GUILD_VOICE: 2, 54 | GROUP_DM: 3, 55 | GUILD_CATEGORY: 4, 56 | GUILD_NEWS: 5, 57 | GUILD_STORE: 6, 58 | }, 59 | DISCORD_API_URL: 'https://discordapp.com/api', 60 | DISCORD_API_DEFAULT_VERSION: 'v6', 61 | /** Discord epoch (2015-01-01T00:00:00.000Z) */ 62 | DISCORD_EPOCH: 1420070400000, 63 | DISCORD_CDN_URL: 'https://cdn.discordapp.com/', 64 | PARACORD_SWEEP_INTERVAL: 500, 65 | /** A permissions map for operations relevant to the library. */ 66 | PERMISSIONS: { 67 | ADMINISTRATOR: 0x8, 68 | }, 69 | /** For internal logging. */ 70 | LOG_SOURCES: { 71 | GATEWAY: 0, 72 | API: 1, 73 | PARACORD: 2, 74 | RPC: 3, 75 | }, 76 | LOG_LEVELS: { 77 | FATAL: 0, 78 | ERROR: 1, 79 | WARNING: 2, 80 | INFO: 4, 81 | DEBUG: 5, 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /src/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "main": "./Utils.js" 4 | } -------------------------------------------------------------------------------- /src/utils/tests/Util.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const sinon = require('sinon'); 5 | const Utils = require('../Utils'); 6 | 7 | describe('Utils', () => { 8 | describe('timestampFromSnowflake', () => { 9 | it('.', () => { 10 | const snowflake = '399864099946889216'; 11 | 12 | const got = Utils.timestampFromSnowflake(snowflake); 13 | 14 | assert.deepStrictEqual(got, 1515405430543); 15 | }); 16 | }); 17 | 18 | describe('computeGuildPerms', () => { 19 | it('.', () => { 20 | const member = { user: { id: '124' }, roles: [] }; 21 | const guild = { owner_id: '123', roles: new Map() }; 22 | const stub_guild_roles_get = sinon.stub(guild.roles, 'get'); 23 | stub_guild_roles_get.returns({ permissions: 0 }); 24 | 25 | const got = Utils.computeGuildPerms(member, guild); 26 | 27 | sinon.assert.calledOnce(stub_guild_roles_get); 28 | assert.strictEqual(got, 0); 29 | }); 30 | it('..', () => { 31 | const member = { user: { id: '124' }, roles: ['1'] }; 32 | const guild = { id: '125', owner_id: '123', roles: new Map() }; 33 | const stub_guild_roles_get = sinon.stub(guild.roles, 'get'); 34 | stub_guild_roles_get.onCall(0).returns({ permissions: 0 }); 35 | stub_guild_roles_get.onCall(1).returns(undefined); 36 | 37 | const got = Utils.computeGuildPerms(member, guild); 38 | 39 | sinon.assert.calledTwice(stub_guild_roles_get); 40 | assert.strictEqual(got, 0); 41 | assert.strictEqual(stub_guild_roles_get.getCall(0).args[0], '125'); 42 | }); 43 | it('...', () => { 44 | const member = { user: { id: '124' }, roles: ['1'] }; 45 | const guild = { owner_id: '123', roles: new Map() }; 46 | const stub_guild_roles_get = sinon.stub(guild.roles, 'get'); 47 | stub_guild_roles_get.onCall(0).returns({ permissions: 0 }); 48 | stub_guild_roles_get.onCall(1).returns({ permissions: 0x8 }); 49 | 50 | const got = Utils.computeGuildPerms(member, guild); 51 | 52 | sinon.assert.calledTwice(stub_guild_roles_get); 53 | assert.strictEqual(got, 0x8); 54 | assert.strictEqual(stub_guild_roles_get.getCall(1).args[0], '1'); 55 | }); 56 | it('....', () => { 57 | const member = { user: { id: '124' }, roles: ['1', '2'] }; 58 | const guild = { owner_id: '123', roles: new Map() }; 59 | const stub_guild_roles_get = sinon.stub(guild.roles, 'get'); 60 | stub_guild_roles_get.onCall(0).returns({ permissions: 0 }); 61 | stub_guild_roles_get.onCall(1).returns({ permissions: 0x1 }); 62 | stub_guild_roles_get.onCall(2).returns({ permissions: 0x2 }); 63 | 64 | const got = Utils.computeGuildPerms(member, guild); 65 | 66 | sinon.assert.calledThrice(stub_guild_roles_get); 67 | assert.strictEqual(got, 0x3); 68 | assert.strictEqual(stub_guild_roles_get.getCall(2).args[0], '2'); 69 | }); 70 | }); 71 | 72 | describe('_everyoneOverwrites', () => { 73 | it('.', () => { 74 | const perms = 0; 75 | const overwrites = []; 76 | const guildID = '123'; 77 | 78 | const got = Utils._everyoneOverwrites(perms, overwrites, guildID); 79 | 80 | assert.strictEqual(got, 0); 81 | }); 82 | it('..', () => { 83 | const perms = 0x1; 84 | const overwrites = [{ 85 | type: 'role', id: '123', allow: 0x2, deny: 0x0, 86 | }]; 87 | const guildID = '123'; 88 | 89 | const got = Utils._everyoneOverwrites(perms, overwrites, guildID); 90 | 91 | assert.strictEqual(got, 0x3); 92 | }); 93 | it('...', () => { 94 | const perms = 0x1; 95 | const overwrites = [{ 96 | type: 'role', id: '123', allow: 0x2, deny: 0x1, 97 | }]; 98 | const guildID = '123'; 99 | 100 | const got = Utils._everyoneOverwrites(perms, overwrites, guildID); 101 | 102 | assert.strictEqual(got, 0x2); 103 | }); 104 | it('....', () => { 105 | const perms = 0x1; 106 | const overwrites = [{ 107 | type: 'member', id: '123', allow: 0x2, deny: 0x1, 108 | }]; 109 | const guildID = '123'; 110 | 111 | const got = Utils._everyoneOverwrites(perms, overwrites, guildID); 112 | 113 | assert.strictEqual(got, 0x1); 114 | }); 115 | }); 116 | 117 | describe('_roleOverwrites', () => { 118 | it('.', () => { 119 | const perms = 0; 120 | const overwrites = []; 121 | const roles = []; 122 | 123 | const got = Utils._roleOverwrites(perms, overwrites, roles); 124 | 125 | assert.strictEqual(got, 0); 126 | }); 127 | it('..', () => { 128 | const perms = 0b01; 129 | const overwrites = [{ 130 | type: 'role', id: '123', allow: 0b10, deny: 0b01, 131 | }]; 132 | const roles = []; 133 | 134 | const got = Utils._roleOverwrites(perms, overwrites, roles); 135 | 136 | assert.strictEqual(got, 0b01); 137 | }); 138 | it('...', () => { 139 | const perms = 0b01; 140 | const overwrites = [{ type: 'role', id: '123', allow: 0b10 }]; 141 | const roles = ['123']; 142 | 143 | const got = Utils._roleOverwrites(perms, overwrites, roles); 144 | 145 | assert.strictEqual(got, 0b11); 146 | }); 147 | it('....', () => { 148 | const perms = 0b01; 149 | const overwrites = [{ 150 | type: 'role', id: '123', allow: 0b10, deny: 0b01, 151 | }]; 152 | const roles = ['123']; 153 | 154 | const got = Utils._roleOverwrites(perms, overwrites, roles); 155 | 156 | assert.strictEqual(got, 0b10); 157 | }); 158 | it('.....', () => { 159 | const perms = 0b0001; 160 | const overwrites = [ 161 | { type: 'role', id: '123', allow: 0b0010 }, 162 | { type: 'role', id: '456', allow: 0b0100 }, 163 | { type: 'role', id: '789', allow: 0b1000 }, 164 | ]; 165 | const roles = ['123', '789']; 166 | 167 | const got = Utils._roleOverwrites(perms, overwrites, roles); 168 | 169 | assert.strictEqual(got, 0b1011); 170 | }); 171 | it('......', () => { 172 | const perms = 0b0001; 173 | const overwrites = [ 174 | { type: 'role', id: '123', allow: 0b0010 }, 175 | { type: 'role', id: '456', allow: 0b0100 }, 176 | { type: 'member', id: '789', allow: 0b1000 }, 177 | ]; 178 | const roles = ['123', '789']; 179 | 180 | const got = Utils._roleOverwrites(perms, overwrites, roles); 181 | 182 | assert.strictEqual(got, 0b0011); 183 | }); 184 | }); 185 | 186 | describe('_memberOverwrites', () => { 187 | it('.', () => { 188 | const perms = 0; 189 | const overwrites = []; 190 | const memberID = '123'; 191 | 192 | const got = Utils._memberOverwrites(perms, overwrites, memberID); 193 | 194 | assert.strictEqual(got, 0); 195 | }); 196 | it('..', () => { 197 | const perms = 0x1; 198 | const overwrites = [{ 199 | type: 'member', id: '123', allow: 0x2, deny: 0x0, 200 | }]; 201 | const memberID = '123'; 202 | 203 | const got = Utils._memberOverwrites(perms, overwrites, memberID); 204 | 205 | assert.strictEqual(got, 0x3); 206 | }); 207 | it('...', () => { 208 | const perms = 0x1; 209 | const overwrites = [{ 210 | type: 'member', id: '123', allow: 0x2, deny: 0x1, 211 | }]; 212 | const memberID = '123'; 213 | 214 | const got = Utils._memberOverwrites(perms, overwrites, memberID); 215 | 216 | assert.strictEqual(got, 0x2); 217 | }); 218 | it('....', () => { 219 | const perms = 0x0; 220 | const overwrites = [{ type: 'member', id: '123', deny: 0x1 }]; 221 | const memberID = '123'; 222 | 223 | const got = Utils._memberOverwrites(perms, overwrites, memberID); 224 | 225 | assert.strictEqual(got, 0x0); 226 | }); 227 | it('.....', () => { 228 | const perms = 0x1; 229 | const overwrites = [{ 230 | type: 'role', id: '123', allow: 0x2, deny: 0x1, 231 | }]; 232 | const memberID = '123'; 233 | 234 | const got = Utils._memberOverwrites(perms, overwrites, memberID); 235 | 236 | assert.strictEqual(got, 0x1); 237 | }); 238 | }); 239 | 240 | describe('computeChannelOverwrites', () => { 241 | it('.', () => { 242 | const stub_everyoneOverwrites = sinon.stub(Utils, '_everyoneOverwrites'); 243 | const stub_roleOverwrites = sinon.stub(Utils, '_roleOverwrites'); 244 | const stub_memberOverwrites = sinon 245 | .stub(Utils, '_memberOverwrites') 246 | .returns(1); 247 | 248 | const perms = 0; 249 | const member = { user: {} }; 250 | const guild = {}; 251 | const channel = { permission_overwrites: [] }; 252 | 253 | const got = Utils.computeChannelOverwrites(perms, member, guild, channel); 254 | 255 | sinon.assert.calledOnce(stub_everyoneOverwrites); 256 | sinon.assert.calledOnce(stub_roleOverwrites); 257 | sinon.assert.calledOnce(stub_memberOverwrites); 258 | assert.strictEqual(got, 1); 259 | }); 260 | }); 261 | 262 | describe('computeChannelPerms', () => { 263 | it('.', () => { 264 | const stub_computeGuildPerms = sinon 265 | .stub(Utils, 'computeGuildPerms') 266 | .returns(0x8); 267 | const stub_computerChannelOverwrites = sinon.stub( 268 | Utils, 269 | 'computeChannelOverwrites', 270 | ); 271 | 272 | const got = Utils.computeChannelPerms(null, null, null, true); 273 | 274 | sinon.assert.calledOnce(stub_computeGuildPerms); 275 | sinon.assert.notCalled(stub_computerChannelOverwrites); 276 | assert.strictEqual(got, 0x8); 277 | }); 278 | it('..', () => { 279 | const stub_computeGuildPerms = sinon.stub(Utils, 'computeGuildPerms'); 280 | const stub_computerChannelOverwrites = sinon 281 | .stub(Utils, 'computeChannelOverwrites') 282 | .returns(1); 283 | 284 | const got = Utils.computeChannelPerms(); 285 | 286 | sinon.assert.calledOnce(stub_computeGuildPerms); 287 | sinon.assert.calledOnce(stub_computerChannelOverwrites); 288 | assert.strictEqual(got, 1); 289 | }); 290 | }); 291 | }); 292 | -------------------------------------------------------------------------------- /tests/smoke.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | describe('smoke test', () => { 6 | it('checks equality', () => { 7 | assert.strictEqual(true, true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /tests/suite.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | const FAILED_TESTS = {}; 6 | 7 | // const originalLogFunction = console.log; 8 | // let output; 9 | 10 | beforeEach(function () { 11 | // output = ""; 12 | // console.log = (...msgs) => { 13 | // if (Array.isArray(msgs)) { 14 | // const tmp = []; 15 | // for (const msg of msgs) { 16 | // if (msg === null) { 17 | // tmp.push("null"); 18 | // } else if (msg === undefined) { 19 | // tmp.push("undefined"); 20 | // } else { 21 | // tmp.push(msg); 22 | // } 23 | // } 24 | 25 | // output += tmp.join(" ") + "\n"; 26 | // } else { 27 | // output += msgs === null ? "null" : msgs === undefined ? "undefined" : msgs + "\n"; 28 | // } 29 | // }; 30 | 31 | if (FAILED_TESTS[this.currentTest.file]) { 32 | this.skip(); 33 | } 34 | }); 35 | 36 | afterEach(function () { 37 | // console.log = originalLogFunction; // undo dummy log function 38 | 39 | sinon.restore(); 40 | if (this.currentTest.state === 'failed') { 41 | // console.log(output); 42 | FAILED_TESTS[this.currentTest.file] = true; 43 | } 44 | }); 45 | --------------------------------------------------------------------------------