├── .eslintrc.js ├── .gitignore ├── .npmrc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package.json └── src ├── bitfield.js ├── client ├── base.js ├── gateway.js ├── index.js └── interaction.js ├── constants.js ├── gateway ├── events.js ├── index.js └── socket.js ├── index.js ├── message.js ├── rest ├── error.js └── index.js ├── state.js ├── store.js ├── structures ├── channel.js ├── guild.js ├── guild_member.js ├── index.js ├── interaction.js ├── message.js ├── structure.js ├── user.js └── webhook.js └── voice ├── index.js ├── readable.js └── writable.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | extends: 'airbnb-base', 6 | plugins: ['@babel'], 7 | parserOptions: { 8 | ecmaVersion: 2021, 9 | sourceType: 'script', 10 | requireConfigFile: false, 11 | }, 12 | env: { 13 | es6: true, 14 | node: true, 15 | }, 16 | overrides: [ 17 | { 18 | files: ['*.jsx'], 19 | parserOptions: { 20 | sourceType: 'module', 21 | ecmaFeatures: { jsx: true }, 22 | }, 23 | }, 24 | { 25 | files: ['*.mjs'], 26 | parserOptions: { sourceType: 'module' }, 27 | env: { 28 | node: true, 29 | }, 30 | rules: { 31 | 'no-restricted-globals': ['error', 'require'], 32 | }, 33 | }, 34 | { 35 | files: ['*.web.js'], 36 | env: { browser: true }, 37 | }, 38 | ], 39 | rules: { 40 | 'strict': ['error', 'global'], 41 | 'indent': ['error', 2, { 42 | SwitchCase: 1, 43 | FunctionDeclaration: { 44 | parameters: 'first', 45 | }, 46 | FunctionExpression: { 47 | parameters: 'first', 48 | }, 49 | CallExpression: { 50 | arguments: 'first', 51 | }, 52 | }], 53 | 'no-bitwise': 'off', 54 | 'no-iterator': 'off', 55 | 'global-require': 'off', 56 | 'quote-props': ['error', 'consistent-as-needed'], 57 | 'brace-style': ['error', '1tbs', { allowSingleLine: false }], 58 | 'curly': ['error', 'all'], 59 | 'no-param-reassign': 'off', 60 | 'arrow-parens': ['error', 'always'], 61 | 'no-multi-assign': 'off', 62 | 'no-underscore-dangle': 'off', 63 | 'no-restricted-syntax': 'off', 64 | 'object-curly-newline': 'off', 65 | 'prefer-const': ['error', { destructuring: 'all' }], 66 | 'class-methods-use-this': 'off', 67 | 'implicit-arrow-linebreak': 'off', 68 | 'lines-between-class-members': 'off', 69 | 'import/no-dynamic-require': 'off', 70 | 'import/no-extraneous-dependencies': ['error', { 71 | devDependencies: true, 72 | }], 73 | 'import/extensions': 'off', 74 | 'import/prefer-default-export': 'off', 75 | 'max-classes-per-file': 'off', 76 | 'no-unused-expressions': 'off', 77 | '@babel/no-unused-expressions': 'error', 78 | }, 79 | globals: { 80 | WebAssembly: false, 81 | BigInt: false, 82 | BigInt64Array: false, 83 | BigUint64Array: false, 84 | URL: false, 85 | Atomics: false, 86 | SharedArrayBuffer: false, 87 | globalThis: false, 88 | FinalizationRegistry: false, 89 | WeakRef: false, 90 | queueMicrotask: false, 91 | }, 92 | }; 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test.js 2 | node_modules 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at devsnek@users.noreply.github.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 snek 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Secret 2 | 3 | Secret is a low-level library for Discord. 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "secret", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "src/index.js", 6 | "author": "snek", 7 | "license": "MIT", 8 | "dependencies": { 9 | "form-data": "^4.0.0", 10 | "node-fetch": "^2.6.1", 11 | "sodium": "^3.0.2", 12 | "ws": "^7.4.4", 13 | "zlib-sync": "^0.1.7" 14 | }, 15 | "devDependencies": { 16 | "@babel/eslint-plugin": "^7.13.16", 17 | "eslint": "^7.25.0", 18 | "eslint-config-airbnb-base": "^14.2.1", 19 | "eslint-plugin-import": "^2.22.1" 20 | }, 21 | "scripts": { 22 | "lint": "eslint src" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/bitfield.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class Bitfield { 4 | constructor(v) { 5 | this.value = 0; 6 | if (v) { 7 | for (const b of v) { 8 | this.add(b); 9 | } 10 | } 11 | } 12 | 13 | getBit(b) { 14 | if (typeof b === 'number') { 15 | return b; 16 | } 17 | return 1 << this.constructor.FIELDS.indexOf(b); 18 | } 19 | 20 | add(bit) { 21 | this.value |= this.getBit(bit); 22 | } 23 | 24 | remove(bit) { 25 | this.value &= ~this.getBit(bit); 26 | } 27 | 28 | has(b) { 29 | const bit = this.getBit(b); 30 | return (this.value & bit) === bit; 31 | } 32 | } 33 | 34 | module.exports = { Bitfield }; 35 | -------------------------------------------------------------------------------- /src/client/base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { EventEmitter } = require('events'); 4 | const { Rest } = require('../rest'); 5 | const { Channel } = require('../structures/channel'); 6 | 7 | class BaseClient extends EventEmitter { 8 | constructor({ token }) { 9 | super(); 10 | this.token = token; 11 | this.applicationID = undefined; 12 | this.rest = new Rest(this); 13 | } 14 | 15 | async getChannel(id) { 16 | const data = await this.rest.get`/channels/${id}`(); 17 | return new Channel(this, data); 18 | } 19 | 20 | async setApplicationCommands(commands) { 21 | await this.rest.put`/applications/${this.applicationID}/commands`({ 22 | data: commands, 23 | }); 24 | } 25 | 26 | async login() { 27 | const app = await this.rest.get`/oauth2/applications/@me`(); 28 | this.applicationID = app.id; 29 | } 30 | } 31 | 32 | module.exports = { BaseClient }; 33 | -------------------------------------------------------------------------------- /src/client/gateway.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Gateway } = require('../gateway'); 4 | const { State } = require('../state'); 5 | const { BaseClient } = require('./base'); 6 | const { ApplicationCommandBuilder } = require('./interaction'); 7 | 8 | class GatewayClient extends BaseClient { 9 | constructor(options) { 10 | super(options); 11 | 12 | this.gateway = new Gateway(this, options.intents); 13 | this.state = new State(this); 14 | 15 | this.commands = new ApplicationCommandBuilder(this); 16 | 17 | this.user = undefined; 18 | 19 | this.on('GUILD_DELETE', (guild) => { 20 | if (!guild.data.unavailable) { 21 | this.voiceStates.delete(guild.data.id); 22 | } 23 | }); 24 | } 25 | 26 | async login() { 27 | await super.login(); 28 | await Promise.all([ 29 | this.commands.announce(), 30 | this.gateway.connect(), 31 | ]); 32 | } 33 | } 34 | 35 | module.exports = { GatewayClient }; 36 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | [ 4 | './base', 5 | './gateway', 6 | './interaction', 7 | ].forEach((r) => { 8 | Object.assign(module.exports, require(r)); 9 | }); 10 | -------------------------------------------------------------------------------- /src/client/interaction.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | const { 5 | InteractionTypes, 6 | InteractionCallbackTypes, 7 | ApplicationCommandOptionTypes, 8 | } = require('../constants'); 9 | const { 10 | Interaction, 11 | User, 12 | Channel, 13 | GuildRole, 14 | } = require('../structures'); 15 | const { BaseClient } = require('./base'); 16 | 17 | class TypeBuilder { 18 | constructor(type, description) { 19 | this.type = type; 20 | this.description = description; 21 | this._required = false; 22 | } 23 | 24 | required(required = true) { 25 | this._required = required; 26 | return this; 27 | } 28 | 29 | serialize() { 30 | return { 31 | type: this.type, 32 | description: this.description, 33 | required: this._required, 34 | }; 35 | } 36 | } 37 | 38 | const TYPES = {}; 39 | Object.entries(ApplicationCommandOptionTypes) 40 | .forEach(([k, v]) => { 41 | TYPES[k.toLowerCase()] = (d) => new TypeBuilder(v, d); 42 | }); 43 | 44 | class ApplicationCommand { 45 | constructor(config, handler) { 46 | this.config = { ...config }; 47 | this.handler = handler; 48 | 49 | if (typeof this.config.options === 'function') { 50 | const built = this.config.options(TYPES); 51 | this.config.options = Object.entries(built) 52 | .map(([k, v]) => ({ name: k, ...v.serialize() })); 53 | } 54 | } 55 | 56 | serialize(type) { 57 | return { 58 | type, 59 | ...this.config, 60 | }; 61 | } 62 | } 63 | 64 | class ApplicationCommandGroup { 65 | constructor(config) { 66 | this.config = config; 67 | this.commands = Object.create(null); 68 | } 69 | 70 | registerGroup(config) { 71 | const group = new ApplicationCommandGroup(config); 72 | this.commands[config.name] = group; 73 | return group; 74 | } 75 | 76 | register(config, handler) { 77 | this.commands[config.name] = new ApplicationCommand(config, handler); 78 | } 79 | 80 | serialize() { 81 | if (this.config) { 82 | return { 83 | name: this.config.name, 84 | description: 'description', 85 | options: Object.values(this.commands) 86 | .map((c) => c.serialize(ApplicationCommandOptionTypes.SUB_COMMAND)), 87 | }; 88 | } 89 | const global = []; 90 | const guild = []; 91 | Object.values(this.commands) 92 | .forEach((command) => { 93 | if (command.config.scope === 'guild') { 94 | guild.push(command.serialize()); 95 | } else { 96 | global.push(command.serialize()); 97 | } 98 | }); 99 | return { global, guild }; 100 | } 101 | } 102 | 103 | class ApplicationCommandBuilder { 104 | constructor(client) { 105 | this.client = client; 106 | 107 | this.rootGroup = new ApplicationCommandGroup(); 108 | 109 | this.guildCommands = undefined; 110 | 111 | this.client.on('GUILD_CREATE', (guild) => { 112 | if (this.guildCommands.length > 0) { 113 | guild.setApplicationCommands(this.guildCommands) 114 | .catch((e) => this.client.emit('error', e)); 115 | } 116 | }); 117 | } 118 | 119 | announce() { 120 | const { global, guild } = this.rootGroup.serialize(); 121 | this.guildCommands = guild; 122 | 123 | if (global.length > 0) { 124 | this.client.setApplicationCommands(global) 125 | .catch((e) => this.client.emit('error', e)); 126 | } 127 | 128 | if (guild.length > 0 || global.length > 0) { 129 | this.client.on('INTERACTION_CREATE', async (interaction) => { 130 | if (interaction.data.type !== InteractionTypes.APPLICATION_COMMAND) { 131 | return; 132 | } 133 | 134 | let command; 135 | let options; 136 | if (interaction.data.data.name in this.rootGroup.commands) { 137 | command = this.rootGroup.commands[interaction.data.data.name]; 138 | options = interaction.data.data.options; 139 | while (command.commands) { 140 | command = command.commands[options[0].name]; 141 | options = options[0].options; 142 | } 143 | } 144 | 145 | if (!command) { 146 | return; 147 | } 148 | 149 | const { resolved } = interaction.data.data; 150 | const gets = {}; 151 | const asyncOps = []; 152 | const args = {}; 153 | options?.forEach((option) => { 154 | switch (option.type) { 155 | case ApplicationCommandOptionTypes.USER: 156 | args[option.name] = new User(this.client, resolved.users[option.value]); 157 | break; 158 | case ApplicationCommandOptionTypes.ROLE: 159 | args[option.name] = new GuildRole(this.client, resolved.roles[option.value]); 160 | break; 161 | case ApplicationCommandOptionTypes.MENTIONABLE: 162 | args[option.name] = resolved.users[option.value] 163 | ? new User(this.client, resolved.users[option.value]) 164 | : new GuildRole(this.client, resolved.roles[option.value]); 165 | break; 166 | case ApplicationCommandOptionTypes.CHANNEL: 167 | asyncOps.push((async () => { 168 | try { 169 | gets[option.value] ||= this.client.getChannel(option.value); 170 | args[option.name] = await gets[option.value]; 171 | } catch { 172 | const c = new Channel(this.client, resolved.channels[option.value]); 173 | c.data.guild_id ||= interaction.data.guild_id; 174 | args[option.name] = c; 175 | } 176 | })()); 177 | break; 178 | default: 179 | args[option.name] = option.value; 180 | break; 181 | } 182 | }); 183 | 184 | await Promise.all(asyncOps); 185 | 186 | await command.handler(interaction, args); 187 | }); 188 | } 189 | } 190 | 191 | registerGroup(config) { 192 | return this.rootGroup.registerGroup(config); 193 | } 194 | 195 | register(config, handler) { 196 | return this.rootGroup.register(config, handler); 197 | } 198 | } 199 | 200 | class InteractionClient extends BaseClient { 201 | constructor(options) { 202 | super(options); 203 | 204 | this.commands = new ApplicationCommandBuilder(this); 205 | 206 | this.publicKey = crypto.webcrypto.subtle.importKey( 207 | 'raw', 208 | Buffer.from(options.publicKey, 'hex'), 209 | { 210 | name: 'NODE-ED25519', 211 | namedCurve: 'NODE-ED25519', 212 | public: true, 213 | }, 214 | true, 215 | ['verify'], 216 | ); 217 | } 218 | 219 | async verify(signature, body, timestamp) { 220 | signature = Buffer.from(signature); 221 | const unknown = Buffer.concat([Buffer.from(timestamp), Buffer.from(body)]); 222 | const publicKey = await this.publicKey; 223 | const v = await crypto.webcrypto.subtle.verify('NODE-ED25519', publicKey, signature, unknown); 224 | return v; 225 | } 226 | 227 | async handle(signature, body, timestamp) { 228 | if (!await this.verify(signature, body, timestamp)) { 229 | throw new Error('Invalid payload'); 230 | } 231 | 232 | const data = JSON.parse(body.toString('utf8')); 233 | 234 | if (data.type === InteractionTypes.PING) { 235 | return { 236 | type: InteractionCallbackTypes.PONG, 237 | }; 238 | } 239 | 240 | const result = await new Promise((resolve) => { 241 | const interaction = new Interaction(this, data, { 242 | reply: resolve, 243 | }); 244 | this.emit('INTERACTION_CREATE', interaction); 245 | }); 246 | 247 | return result; 248 | } 249 | } 250 | 251 | module.exports = { 252 | InteractionClient, 253 | ApplicationCommandBuilder, 254 | }; 255 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const packageInfo = require('../package'); 4 | const { Bitfield } = require('./bitfield'); 5 | 6 | class GatewayIntents extends Bitfield {} 7 | GatewayIntents.FIELDS = [ 8 | 'GUILDS', 9 | 'GUILD_MEMBERS', 10 | 'GUILD_BANS', 11 | 'GUILD_EMOJIS', 12 | 'GUILD_INTEGRATIONS', 13 | 'GUILD_WEBOOKS', 14 | 'GUILD_INVITES', 15 | 'GUILD_VOICE_STATES', 16 | 'GUILD_PRESENCES', 17 | 'GUILD_MESSAGES', 18 | 'GUILD_MESSAGE_REACTIONS', 19 | 'GUILD_MESSAGE_TYPING', 20 | 'DIRECT_MESSAGES', 21 | 'DIRECT_MESSAGE_REACTIONS', 22 | 'DIRECT_MESSAGE_TYPING', 23 | ]; 24 | 25 | const Gateway = { 26 | VERSION: 10, 27 | DEVICE: packageInfo.name, 28 | Intents: GatewayIntents, 29 | Opcodes: { 30 | DISPATCH: 0, 31 | HEARTBEAT: 1, 32 | IDENTIFY: 2, 33 | PRESENCE_UPDATE: 3, 34 | VOICE_STATE_UPDATE: 4, 35 | RESUME: 6, 36 | RECONNECT: 7, 37 | REQUEST_GUILD_MEMBERS: 8, 38 | INVALID_SESSION: 9, 39 | HELLO: 10, 40 | HEARTBEAT_ACK: 11, 41 | }, 42 | }; 43 | 44 | const Rest = { 45 | VERSION: 10, 46 | API: 'https://discord.com/api', 47 | USER_AGENT: `DiscordBot (${packageInfo.name}, ${packageInfo.version}) Node.js/${process.version}`, 48 | }; 49 | 50 | const Voice = { 51 | VERSION: 4, 52 | SUPPORTED_MODES: [ 53 | 'xsalsa20_poly1305_lite', 54 | 'xsalsa20_poly1305_suffix', 55 | 'xsalsa20_poly1305', 56 | ], 57 | Opcodes: { 58 | IDENTIFY: 0, 59 | SELECT_PROTOCOL: 1, 60 | READY: 2, 61 | HEARTBEAT: 3, 62 | SELECT_PROTOCOL_ACK: 4, 63 | SPEAKING: 5, 64 | HEARTBEAT_ACK: 6, 65 | RESUME: 7, 66 | HELLO: 8, 67 | RESUMED: 9, 68 | VIDEO: 12, 69 | CLIENT_DISCONNECT: 13, 70 | SESSION_UPDATE: 14, 71 | VIDEO_SINK_WANTS: 15, 72 | }, 73 | }; 74 | 75 | const InteractionTypes = { 76 | PING: 1, 77 | APPLICATION_COMMAND: 2, 78 | MESSAGE_COMPONENT: 3, 79 | }; 80 | 81 | const InteractionCallbackTypes = { 82 | PONG: 1, 83 | CHANNEL_MESSAGE_WITH_SOURCE: 4, 84 | DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5, 85 | DEFERRED_UPDATE_MESSAGE: 6, 86 | UPDATE_MESSAGE: 7, 87 | }; 88 | 89 | const ApplicationCommandOptionTypes = { 90 | SUB_COMMAND: 1, 91 | SUB_COMMAND_GROUP: 2, 92 | STRING: 3, 93 | INTEGER: 4, 94 | BOOLEAN: 5, 95 | USER: 6, 96 | CHANNEL: 7, 97 | ROLE: 8, 98 | MENTIONABLE: 9, 99 | }; 100 | 101 | module.exports = { 102 | Gateway, 103 | Rest, 104 | Voice, 105 | InteractionTypes, 106 | InteractionCallbackTypes, 107 | ApplicationCommandOptionTypes, 108 | EPOCH: 1420070400000, 109 | }; 110 | -------------------------------------------------------------------------------- /src/gateway/events.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { 4 | Structure, 5 | Channel, 6 | Guild, 7 | GuildMember, 8 | Message, 9 | Interaction, 10 | User, 11 | } = require('../structures'); 12 | 13 | function transform(client, type, data) { 14 | switch (type) { 15 | case 'CHANNEL_CREATE': 16 | case 'CHANNEL_UPDATE': 17 | case 'CHANNEL_DELETE': 18 | return new Channel(client, data); 19 | case 'THREAD_CREATE': 20 | case 'THREAD_UPDATE': 21 | case 'THREAD_DELETE': 22 | return new Channel(client, data); 23 | case 'GUILD_CREATE': 24 | case 'GUILD_UPDATE': 25 | case 'GUILD_DELETE': 26 | return new Guild(client, data); 27 | case 'GUILD_MEMBER_ADD': 28 | case 'GUILD_MEMBER_REMOVE': 29 | case 'GUILD_MEMBER_UPDATE': 30 | return new GuildMember(client, data); 31 | /* 32 | case 'GUILD_ROLE_CREATE': 33 | case 'GUILD_ROLE_UPDATE': 34 | case 'GUILD_ROLE_DELETE': 35 | return new GuildRole(client, data); 36 | */ 37 | case 'INTERACTION_CREATE': 38 | return new Interaction(client, data); 39 | case 'MESSAGE_CREATE': 40 | case 'MESSAGE_UPDATE': 41 | case 'MESSAGE_DELETE': 42 | return new Message(client, data); 43 | case 'USER_UPDATE': 44 | return new User(client, data); 45 | default: 46 | return new Structure(client, data); 47 | } 48 | } 49 | 50 | module.exports = { transform }; 51 | -------------------------------------------------------------------------------- /src/gateway/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { setTimeout } = require('timers/promises'); 4 | 5 | const { GatewaySocket } = require('./socket'); 6 | const { Gateway: { Intents } } = require('../constants'); 7 | 8 | class Gateway { 9 | constructor(client, intents) { 10 | this.client = client; 11 | this.intents = new Intents(intents); 12 | this.endpoint = undefined; 13 | this.shardCount = -1; 14 | this.maxConcurrency = Infinity; 15 | this.shards = []; 16 | this.chain = Promise.resolve(); 17 | this.remaining = 1; 18 | } 19 | 20 | spawnShard(i) { 21 | this.chain = this.chain.then(async () => { 22 | if (this.remaining <= 0) { 23 | await setTimeout(5000); 24 | this.remaining = this.maxConcurrency; 25 | } 26 | this.shards[i] = new GatewaySocket(this, i); 27 | this.shards[i].connect(); 28 | this.remaining -= 1; 29 | }); 30 | } 31 | 32 | forGuild(id) { 33 | const shardID = Number(BigInt(id) >> 22n) % this.shardCount; 34 | return this.shards[shardID]; 35 | } 36 | 37 | async connect() { 38 | if (this.maxConcurrency === Infinity) { 39 | const data = await this.client.rest.get`/gateway/bot`(); 40 | this.endpoint = data.url; 41 | this.shardCount = data.shards; 42 | this.maxConcurrency = data.session_start_limit.max_concurrency; 43 | this.remaining = this.maxConcurrency; 44 | } 45 | this.shards = Array.from({ length: this.shardCount }, (_, i) => { 46 | this.spawnShard(i); 47 | return undefined; 48 | }); 49 | } 50 | } 51 | 52 | module.exports = { Gateway }; 53 | -------------------------------------------------------------------------------- /src/gateway/socket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const WebSocket = require('ws'); 4 | const zlib = require('zlib-sync'); 5 | 6 | const { User } = require('../structures/user'); 7 | const { Gateway } = require('../constants'); 8 | const { transform } = require('./events'); 9 | 10 | class GatewaySocket { 11 | constructor(gateway, shardID) { 12 | this.gateway = gateway; 13 | this.client = gateway.client; 14 | this.shardID = shardID; 15 | 16 | this.inflate = undefined; 17 | this.socket = undefined; 18 | this.sequence = -1; 19 | this.sessionID = undefined; 20 | this.lastHeartbeatAcked = false; 21 | this.resumeGatewayURL = undefined; 22 | } 23 | 24 | get endpoint() { 25 | return this.resumeGatewayURL ?? this.gateway.endpoint; 26 | } 27 | 28 | connect() { 29 | this.disconnect(); 30 | this.inflate = new zlib.Inflate({ 31 | chunkSize: 65535, 32 | flush: zlib.Z_SYNC_FLUSH, 33 | }); 34 | this.socket = new WebSocket(`${this.endpoint}?v=${Gateway.VERSION}&encoding=json&compress=zlib-stream`); 35 | this.socket.onopen = this.onOpen.bind(this); 36 | this.socket.onmessage = this.onMessage.bind(this); 37 | this.socket.onerror = this.onError.bind(this); 38 | this.socket.onclose = this.onClose.bind(this); 39 | } 40 | 41 | disconnect(code = 1001) { 42 | clearInterval(this.heartbeatInterval); 43 | if (this.socket) { 44 | try { 45 | this.socket.close(code); 46 | } catch { 47 | // nothing 48 | } 49 | this.socket = undefined; 50 | } 51 | } 52 | 53 | onOpen() {} 54 | 55 | onMessage({ data }) { 56 | if (data instanceof ArrayBuffer) { 57 | data = Buffer.from(data); 58 | } 59 | const flush = data.length >= 4 && data.readUint32BE(data.length - 4) === 0x0000FFFF; 60 | this.inflate.push(data, flush && zlib.Z_SYNC_FLUSH); 61 | if (!flush) { 62 | return; 63 | } 64 | this.onPacket(JSON.parse(this.inflate.result)); 65 | } 66 | 67 | onError() {} 68 | 69 | onClose(e) { 70 | switch (e.code) { 71 | case 1001: 72 | break; 73 | case 1000: 74 | case 4006: 75 | case 4007: 76 | case 4009: 77 | this.gateway.spawnShard(this.shardID); 78 | break; 79 | case 4004: 80 | case 4010: 81 | case 4011: 82 | case 4012: 83 | case 4013: 84 | case 4014: 85 | throw new Error(e.reason); 86 | default: 87 | this.connect(); 88 | break; 89 | } 90 | } 91 | 92 | send(data) { 93 | this.socket.send(JSON.stringify(data)); 94 | } 95 | 96 | onPacket(packet) { 97 | if (packet.s > this.sequence) { 98 | this.sequence = packet.s; 99 | } 100 | 101 | switch (packet.op) { 102 | case Gateway.Opcodes.HELLO: 103 | this.lastHeartbeatAcked = true; 104 | this.heartbeatInterval = setInterval(() => { 105 | if (!this.lastHeartbeatAcked) { 106 | this.connect(); 107 | } else { 108 | this.lastHeartbeatAcked = false; 109 | this.send({ op: Gateway.Opcodes.HEARTBEAT, d: this.sequence }); 110 | } 111 | }, packet.d.heartbeat_interval); 112 | if (this.sessionID) { 113 | this.send({ 114 | op: Gateway.Opcodes.RESUME, 115 | d: { 116 | token: this.client.token, 117 | session_id: this.sessionID, 118 | seq: this.sequence, 119 | }, 120 | }); 121 | } else { 122 | this.send({ 123 | op: Gateway.Opcodes.IDENTIFY, 124 | d: { 125 | token: this.client.token, 126 | intents: this.gateway.intents.value, 127 | properties: { 128 | $os: process.platform, 129 | $device: Gateway.DEVICE, 130 | $browser: Gateway.DEVICE, 131 | }, 132 | shard: [this.shardID, this.gateway.shardCount], 133 | }, 134 | }); 135 | } 136 | break; 137 | case Gateway.Opcodes.RECONNECT: 138 | if (this.sessionID) { 139 | this.connect(); 140 | } else { 141 | this.gateway.spawnShard(this.shardID); 142 | } 143 | break; 144 | case Gateway.Opcodes.INVALID_SESSION: 145 | if (packet.d && this.sessionID) { 146 | this.connect(); 147 | } else { 148 | this.gateway.spawnShard(this.shardID); 149 | } 150 | break; 151 | case Gateway.Opcodes.DISPATCH: 152 | switch (packet.t) { 153 | case 'READY': 154 | this.sessionID = packet.d.session_id; 155 | this.resumeGatewayURL = packet.d.resume_gateway_url; 156 | this.client.user = new User(this.client, packet.d.user); 157 | this.client.emit(packet.t, transform(this.client, packet.t, packet.d)); 158 | break; 159 | default: 160 | this.client.emit(packet.t, transform(this.client, packet.t, packet.d)); 161 | break; 162 | } 163 | break; 164 | case Gateway.Opcodes.HEARTBEAT_ACK: 165 | this.lastHeartbeatAcked = true; 166 | break; 167 | default: 168 | break; 169 | } 170 | } 171 | } 172 | 173 | module.exports = { GatewaySocket }; 174 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | [ 4 | './client', 5 | './structures', 6 | ].forEach((r) => { 7 | Object.assign(module.exports, require(r)); 8 | }); 9 | -------------------------------------------------------------------------------- /src/message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | async function createRawMessage(client, options = {}, { channelID, messageID }) { 4 | if (typeof options.then === 'function') { 5 | if (channelID) { 6 | await this.client.rest.post`/channels/${channelID}/typing`(); 7 | } 8 | options = await options; 9 | } 10 | 11 | const { attachments, ...optionsWithoutAttachments } = options; 12 | 13 | return { 14 | data: { 15 | ...optionsWithoutAttachments, 16 | content: options.content, 17 | nonce: options.nonce, 18 | tts: options.tts, 19 | embeds: options.embeds, 20 | allowed_mentions: options.allowedMentions, 21 | message_reference: messageID ? { message_id: messageID } : undefined, 22 | flags: options.flags, 23 | }, 24 | files: options.attachments, 25 | }; 26 | } 27 | 28 | module.exports = { createRawMessage }; 29 | -------------------------------------------------------------------------------- /src/rest/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class APIError extends Error { 4 | constructor(response, body) { 5 | super(response.statusText || 'Invalid response'); 6 | this.response = response; 7 | 8 | if (body.code === 50035 && body.errors) { 9 | this.errors = body.errors; 10 | } else { 11 | this.body = body; 12 | } 13 | } 14 | } 15 | 16 | module.exports = { APIError }; 17 | -------------------------------------------------------------------------------- /src/rest/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { METHODS } = require('http'); 4 | const https = require('https'); 5 | const fetch = require('node-fetch'); 6 | const FormData = require('form-data'); 7 | const { setTimeout } = require('timers/promises'); 8 | const { APIError } = require('./error'); 9 | 10 | const { Rest: { API, VERSION, USER_AGENT } } = require('../constants'); 11 | 12 | const agent = new https.Agent({ keepAlive: true }); 13 | 14 | class Bucket { 15 | constructor(rest) { 16 | this.rest = rest; 17 | this.reset = Infinity; 18 | this.remaining = 1; 19 | this.limit = -1; 20 | this.resetAfter = -1; 21 | } 22 | 23 | get limited() { 24 | return this.remaining <= 0 && Date.now() < this.reset; 25 | } 26 | 27 | update(r) { 28 | const serverDate = r.headers.get('date'); 29 | const limit = r.headers.get('x-ratelimit-limit'); 30 | const remaining = r.headers.get('x-ratelimit-remaining'); 31 | const reset = r.headers.get('x-ratelimit-reset'); 32 | const resetAfter = r.headers.get('x-ratelimit-reset-after'); 33 | 34 | this.limit = limit ? Number(limit) : Infinity; 35 | this.remaining = remaining ? Number(remaining) : 1; 36 | this.reset = reset 37 | ? new Date(Number(reset) * 1000).getTime() - (new Date(serverDate).getTime() - Date.now()) 38 | : Date.now(); 39 | this.resetAfter = resetAfter ? Number(resetAfter * 1000) : -1; 40 | } 41 | } 42 | 43 | class SequentialBucket extends Bucket { 44 | constructor(...args) { 45 | super(...args); 46 | this.chain = Promise.resolve(); 47 | } 48 | 49 | queue(request) { 50 | return new Promise((resolve, reject) => { 51 | this.chain = this.chain.then(async () => { 52 | if (this.rest.limited) { 53 | await this.rest.limited; 54 | } 55 | if (this.limited) { 56 | await setTimeout(this.resetAfter); 57 | } 58 | await this.rest.makeRequest(request) 59 | .then(resolve, reject); 60 | }); 61 | }); 62 | } 63 | } 64 | 65 | class Rest { 66 | constructor(client) { 67 | this.client = client; 68 | this.routes = new Map(); 69 | this.buckets = new Map(); 70 | this.limited = undefined; 71 | } 72 | 73 | async makeRequest({ method, path, options, route }) { 74 | let query = ''; 75 | if (options.query) { 76 | query = new URLSearchParams(options.query).toString(); 77 | } 78 | const url = `${API}/v${VERSION}${path}${query ? `?${query}` : ''}`; 79 | 80 | const headers = { 81 | 'User-Agent': USER_AGENT, 82 | }; 83 | if (options.authenticate !== false) { 84 | headers.Authorization = `Bot ${this.client.token}`; 85 | } 86 | if (options.reason) { 87 | headers['X-Audit-Log-Reason'] = options.reason; 88 | } 89 | if (options.headers) { 90 | Object.assign(headers, options.headers); 91 | } 92 | 93 | let body; 94 | if (options.files?.length > 0) { 95 | body = new FormData(); 96 | options.files.forEach((file) => { 97 | body.append(file.name, file.data, file.name); 98 | }); 99 | if (options.data) { 100 | body.append('payload_json', JSON.stringify(options.data)); 101 | } 102 | Object.assign(headers, body.getHeaders()); 103 | } else if (options.data) { 104 | body = JSON.stringify(options.data); 105 | headers['Content-Type'] = 'application/json'; 106 | } 107 | 108 | const r = await fetch(url, { 109 | agent, 110 | method, 111 | headers, 112 | body, 113 | }); 114 | 115 | if (r.status === 429) { 116 | // eslint-disable-next-line no-console 117 | console.warn('[DISCORD] 429', route); 118 | } 119 | 120 | const rbody = r.headers.get('content-type') === 'application/json' 121 | ? await r.json() 122 | : await r.text(); 123 | 124 | if (r.headers.get('x-ratelimit-global')) { 125 | this.limited = setTimeout(rbody.retry_after * 1000).then(() => { 126 | this.limited = undefined; 127 | }); 128 | } 129 | 130 | const bucketHash = r.headers.get('x-ratelimit-bucket'); 131 | 132 | if (!this.buckets.has(bucketHash)) { 133 | this.buckets.set(bucketHash, this.routes.get(route)); 134 | } 135 | this.routes.set(route, this.buckets.get(bucketHash)); 136 | const bucket = this.routes.get(route); 137 | 138 | bucket.update(r); 139 | 140 | if (!r.ok) { 141 | throw new APIError(r, rbody); 142 | } 143 | 144 | return rbody; 145 | } 146 | 147 | queueRequest(method, route, path, options = {}) { 148 | const request = { 149 | method, 150 | route, 151 | path, 152 | options, 153 | }; 154 | 155 | if (!this.routes.has(route)) { 156 | this.routes.set(route, new SequentialBucket(this)); 157 | } 158 | return this.routes.get(route).queue(request); 159 | } 160 | } 161 | 162 | METHODS.forEach((m) => { 163 | Rest.prototype[m.toLowerCase()] = function request(strings, ...args) { 164 | let route = `${m}:`; 165 | strings.forEach((s, i) => { 166 | route += s; 167 | if (i === 0 && ( 168 | s === '/channels/' 169 | || s === '/guilds/' 170 | || s === '/webhooks/' 171 | )) { 172 | route += args[i]; 173 | } else if (i !== strings.length - 1) { 174 | route += ':id'; 175 | } 176 | }); 177 | const built = String.raw(strings, ...args); 178 | return this.queueRequest.bind(this, m, route, built); 179 | }; 180 | }); 181 | 182 | module.exports = { Rest }; 183 | -------------------------------------------------------------------------------- /src/state.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EVENTS = []; 4 | 5 | class State { 6 | constructor(client) { 7 | this.client = client; 8 | 9 | this.voice = new Map(); 10 | 11 | EVENTS.forEach(({ method, event }) => { 12 | this.client.on(event, this[method].bind(this)); 13 | }); 14 | } 15 | } 16 | 17 | Object.getOwnPropertyNames(State.prototype).forEach((m) => { 18 | if (!m.startsWith?.('on')) { 19 | return; 20 | } 21 | 22 | EVENTS.push({ 23 | method: m, 24 | event: m.replace(/[A-Z]/g, (l) => `_${l}`).slice(3).toUpperCase(), 25 | }); 26 | }); 27 | 28 | module.exports = { State }; 29 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class Store { 4 | constructor() { 5 | this.data = new Map(); 6 | } 7 | 8 | get(key) { 9 | return this.data.get(key); 10 | } 11 | 12 | set(key, value) { 13 | return this.data.set(key, value); 14 | } 15 | 16 | has(key) { 17 | return this.data.has(key); 18 | } 19 | 20 | delete(key) { 21 | return this.data.delete(key); 22 | } 23 | 24 | update(items) { 25 | for (const v of items.values()) { 26 | this.set(v.id, v); 27 | } 28 | } 29 | 30 | [Symbol.iterator]() { 31 | return this.data.entries(); 32 | } 33 | 34 | entries() { 35 | return this[Symbol.iterator](); 36 | } 37 | 38 | * keys() { 39 | for (const [k] of this) { 40 | yield k; 41 | } 42 | } 43 | 44 | * values() { 45 | for (const [, v] of this) { 46 | yield v; 47 | } 48 | } 49 | } 50 | 51 | class WeakStore extends Store { 52 | get(key) { 53 | const w = this.data.get(key); 54 | if (w === undefined) { 55 | return undefined; 56 | } 57 | const v = w.deref(); 58 | if (v === undefined) { 59 | this.data.delete(key); 60 | } 61 | return v; 62 | } 63 | 64 | set(key, value) { 65 | const w = new WeakRef(value); 66 | return this.data.set(key, w); 67 | } 68 | 69 | has(key) { 70 | const w = this.data.get(key); 71 | if (w === undefined) { 72 | return false; 73 | } 74 | if (w.deref() === undefined) { 75 | this.data.delete(key); 76 | return false; 77 | } 78 | return true; 79 | } 80 | 81 | delete(key) { 82 | return this.data.delete(key); 83 | } 84 | 85 | * [Symbol.iterator]() { 86 | for (const [k, w] of this.data) { 87 | const v = w.deref(); 88 | if (v === undefined) { 89 | this.data.delete(k); 90 | } else { 91 | yield [k, v]; 92 | } 93 | } 94 | } 95 | } 96 | 97 | module.exports = { Store, WeakStore }; 98 | -------------------------------------------------------------------------------- /src/structures/channel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Structure } = require('./structure'); 4 | const { createRawMessage } = require('../message'); 5 | 6 | class Channel extends Structure { 7 | async sendMessage(options) { 8 | const raw = await createRawMessage(this.client, options, { 9 | channelID: this.data.id, 10 | }); 11 | const data = await this.client.rest.post`/channels/${this.data.id}/messages`({ 12 | data: raw.data, 13 | files: raw.files, 14 | }); 15 | const { Message } = require('./message'); 16 | return new Message(this.client, data); 17 | } 18 | 19 | async getParent() { 20 | if (this.data.parent_id) { 21 | const data = await this.client.rest.get`/channels/${this.data.parent_id}`(); 22 | return new Channel(this.client, data); 23 | } 24 | return undefined; 25 | } 26 | 27 | async getGuild() { 28 | if (this.data.guild_id) { 29 | const { Guild } = require('./guild'); 30 | const data = await this.client.rest.get`/guilds/${this.data.guild_id}`(); 31 | return new Guild(this.client, data); 32 | } 33 | return undefined; 34 | } 35 | 36 | async getThreadMembers() { 37 | const data = await this.client.rest.get`/channels/${this.data.id}/thread-members`(); 38 | return data.map((d) => new Structure(this.client, d)); 39 | } 40 | 41 | async getActiveThreads() { 42 | const data = await this.client.rest.get`/channels/${this.data.id}/threads/active`(); 43 | return data.map((d) => new Channel(this.client, d)); 44 | } 45 | 46 | async getArchivedPublicThreads() { 47 | const data = await this.client.rest.get`/channels/${this.data.id}/threads/archived/public`(); 48 | return data.map((d) => new Channel(this.client, d)); 49 | } 50 | 51 | async getArchivedPrivateThreads() { 52 | const data = await this.client.rest.get`/channels/${this.data.id}/threads/archived/private`(); 53 | return data.map((d) => new Channel(this.client, d)); 54 | } 55 | 56 | async delete() { 57 | await this.client.rest.delete`/channels/${this.data.id}`(); 58 | } 59 | 60 | async join() { 61 | const { VoiceState } = require('../voice'); 62 | if (!this.client.state.voice.has(this.data.guild_id)) { 63 | const state = new VoiceState(this.client, this.data.guild_id); 64 | this.client.state.voice.set(this.data.guild_id, state); 65 | } 66 | const state = this.client.state.voice.get(this.data.guild_id); 67 | await state.connect(this.data.id); 68 | return state; 69 | } 70 | 71 | async createWebhook(options) { 72 | const { Webhook } = require('./webhook'); 73 | const data = await this.client.rest.post`/channels/${this.data.id}/webhooks`({ 74 | data: options, 75 | reason: options.reason, 76 | }); 77 | return new Webhook(this.client, data); 78 | } 79 | 80 | toMention() { 81 | return `<#${this.data.id}>`; 82 | } 83 | } 84 | 85 | module.exports = { Channel }; 86 | -------------------------------------------------------------------------------- /src/structures/guild.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Channel } = require('./channel'); 4 | const { Structure } = require('./structure'); 5 | 6 | class GuildRole extends Structure {} 7 | 8 | class Guild extends Structure { 9 | async getChannels() { 10 | const data = this.data.channels || await this.client.rest.get`/guilds/${this.data.id}/channels`(); 11 | return data.map((c) => new Channel(this.client, c)); 12 | } 13 | 14 | async setApplicationCommands(commands) { 15 | await this.client.rest.put`/applications/${this.client.applicationID}/guilds/${this.data.id}/commands`({ 16 | data: commands, 17 | }); 18 | } 19 | } 20 | 21 | module.exports = { Guild, GuildRole }; 22 | -------------------------------------------------------------------------------- /src/structures/guild_member.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Structure } = require('./structure'); 4 | const { User } = require('./user'); 5 | 6 | class GuildMember extends Structure { 7 | constructor(client, data) { 8 | super(client, data); 9 | 10 | this.user = data.user ? new User(client, data.user) : null; 11 | } 12 | } 13 | 14 | module.exports = { GuildMember }; 15 | -------------------------------------------------------------------------------- /src/structures/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | 5 | fs.readdirSync(__dirname) 6 | .forEach((d) => { 7 | if (d !== 'index.js' && d.endsWith('.js')) { 8 | Object.assign(module.exports, require(`./${d}`)); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /src/structures/interaction.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Structure } = require('./structure'); 4 | const { Channel } = require('./channel'); 5 | const { Guild } = require('./guild'); 6 | const { GuildMember } = require('./guild_member'); 7 | const { Message } = require('./message'); 8 | const { User } = require('./user'); 9 | const { Webhook } = require('./webhook'); 10 | 11 | class Interaction extends Structure { 12 | constructor(client, data, syncHandle) { 13 | super(client, data); 14 | 15 | this.webhook = new Webhook(client, { 16 | id: data.application_id, 17 | token: data.token, 18 | }); 19 | this.member = data.member ? new GuildMember(client, data.member) : null; 20 | this.user = data.user ? new User(client, data.user) : this.member?.user; 21 | this.message = data.message ? new Message(client, data.message) : null; 22 | 23 | this.syncHandle = syncHandle; 24 | } 25 | 26 | async reply(data) { 27 | if (this.syncHandle) { 28 | await this.syncHandle.reply(data); 29 | } else { 30 | await this.client.rest.post`/interactions/${this.data.id}/${this.data.token}/callback`({ 31 | data, 32 | }); 33 | } 34 | } 35 | 36 | async getChannel() { 37 | const data = await this.client.rest.get`/channels/${this.data.channel_id}`(); 38 | return new Channel(this.client, data); 39 | } 40 | 41 | async getGuild() { 42 | const data = await this.client.rest.get`/guilds/${this.data.guild_id}`(); 43 | return new Guild(this.client, data); 44 | } 45 | } 46 | 47 | module.exports = { Interaction }; 48 | -------------------------------------------------------------------------------- /src/structures/message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Structure } = require('./structure'); 4 | const { User } = require('./user'); 5 | const { Guild } = require('./guild'); 6 | const { Channel } = require('./channel'); 7 | const { createRawMessage } = require('../message'); 8 | 9 | class Message extends Structure { 10 | constructor(client, data) { 11 | super(client, data); 12 | 13 | this.author = data.author ? new User(this.client, this.data.author) : undefined; 14 | } 15 | 16 | async reply(options) { 17 | const raw = await createRawMessage(this.client, options, { 18 | channelID: this.data.channel_id, 19 | messageID: this.id, 20 | }); 21 | if (this.data.webhook_id) { 22 | const hook = await this.client.rest.get`/webhooks/${this.data.webhook_id}`(); 23 | const data = await this.client.rest.post`/webhooks/${hook.id}/${hook.token}`({ 24 | authenticate: false, 25 | data: raw.data, 26 | files: raw.files, 27 | }); 28 | return new Message(this.client, data); 29 | } 30 | const data = await this.client.rest.post`/channels/${this.data.channel_id}/messages`({ 31 | data: raw.data, 32 | files: raw.files, 33 | }); 34 | return new Message(this.client, data); 35 | } 36 | 37 | async getChannel() { 38 | const data = await this.client.rest.get`/channels/${this.data.channel_id}`(); 39 | return new Channel(this.client, data); 40 | } 41 | 42 | async delete() { 43 | await this.client.rest.delete`/channels/${this.data.channel_id}/messages/${this.data.id}`(); 44 | } 45 | 46 | async setPinned(pinned) { 47 | if (pinned) { 48 | await this.client.rest.put`/channels/${this.data.channel_id}/pins/${this.data.id}`(); 49 | } else { 50 | await this.client.rest.delete`/channels/${this.data.channel_id}/pins/${this.data.id}`(); 51 | } 52 | } 53 | 54 | async getGuild() { 55 | const data = await this.client.rest.get`/guilds/${this.data.guild_id}`(); 56 | return new Guild(this.client, data); 57 | } 58 | } 59 | 60 | module.exports = { Message }; 61 | -------------------------------------------------------------------------------- /src/structures/structure.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { EPOCH } = require('../constants'); 4 | 5 | class Structure { 6 | constructor(client, data) { 7 | Object.defineProperty(this, 'client', { 8 | value: client, 9 | writable: true, 10 | enumerable: false, 11 | configurable: true, 12 | }); 13 | this.data = data; 14 | 15 | if (data.id) { 16 | this.createdAt = Number((BigInt(data.id) >> 22n) + BigInt(EPOCH)); 17 | } 18 | } 19 | } 20 | 21 | module.exports = { Structure }; 22 | -------------------------------------------------------------------------------- /src/structures/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Structure } = require('./structure'); 4 | 5 | class User extends Structure { 6 | get tag() { 7 | return `${this.data.username}#${this.data.discriminator}`; 8 | } 9 | 10 | toMention() { 11 | return `<@!${this.data.id}>`; 12 | } 13 | } 14 | 15 | module.exports = { User }; 16 | -------------------------------------------------------------------------------- /src/structures/webhook.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Structure } = require('./structure'); 4 | const { Message } = require('./message'); 5 | const { Guild } = require('./guild'); 6 | const { Channel } = require('./channel'); 7 | const { createRawMessage } = require('../message'); 8 | 9 | class Webhook extends Structure { 10 | constructor(client, data) { 11 | super(client, data); 12 | 13 | if (!this.client) { 14 | const { BaseClient } = require('../client'); 15 | this.client = new BaseClient(); 16 | } 17 | } 18 | 19 | async sendMessage(options) { 20 | const raw = await createRawMessage(this.client, options); 21 | const data = await this.client.rest.post`/webhooks/${this.data.id}/${this.data.token}`({ 22 | authenticate: false, 23 | query: { wait: true }, 24 | data: raw.data, 25 | files: raw.files, 26 | }); 27 | return new Message(this.client, data); 28 | } 29 | 30 | async getChannel() { 31 | const data = await this.client.rest.get`/channels/${this.data.channel_id}`(); 32 | return new Channel(this.client, data); 33 | } 34 | 35 | async getGuild() { 36 | if (this.data.guild_id) { 37 | const data = await this.client.rest.get`/guilds/${this.data.guild_id}`(); 38 | return new Guild(this.client, data); 39 | } 40 | return undefined; 41 | } 42 | 43 | async delete({ reason } = {}) { 44 | await this.client.rest.delete`/webhooks/${this.data.id}/${this.data.token}`({ 45 | authenticate: false, 46 | reason, 47 | }); 48 | } 49 | } 50 | 51 | module.exports = { Webhook }; 52 | -------------------------------------------------------------------------------- /src/voice/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const WebSocket = require('ws'); 4 | const { createSocket } = require('dgram'); 5 | const { Gateway, Voice } = require('../constants'); 6 | const { Writable } = require('./writable'); 7 | const { Readable } = require('./readable'); 8 | 9 | const SILENCE_FRAME = Buffer.from([0xF8, 0xFF, 0xFE]); 10 | const UDP_KEEP_ALIVE_INTERVAL = 5000; 11 | 12 | const textDecoder = new TextDecoder(); 13 | 14 | class VoiceState { 15 | constructor(client, guildID) { 16 | this.client = client; 17 | this.guildID = guildID; 18 | 19 | this.ws = undefined; 20 | this.udp = undefined; 21 | 22 | this.wsEndpoint = undefined; 23 | this.sessionID = undefined; 24 | this.token = undefined; 25 | this.ssrc = undefined; 26 | this.mode = undefined; 27 | this.secretKey = undefined; 28 | 29 | this.udpKeepAliveInterval = undefined; 30 | this.speakingState = undefined; 31 | 32 | this.resolveConnect = undefined; 33 | this.rejectConnect = undefined; 34 | 35 | this.writable = new Writable(this); 36 | this.readable = new Readable(this); 37 | } 38 | 39 | async connect(channelID) { 40 | this.writable.cork(); 41 | 42 | if (this.ws) { 43 | this.disconnect(); 44 | } 45 | 46 | this.client.gateway.forGuild(this.guildID).send({ 47 | op: Gateway.Opcodes.VOICE_STATE_UPDATE, 48 | d: { 49 | guild_id: this.guildID, 50 | channel_id: channelID, 51 | self_mute: false, 52 | self_deaf: false, 53 | }, 54 | }); 55 | 56 | const [stateUpdate, serverUpdate] = await Promise.all([ 57 | this.getVoiceStateUpdate(), 58 | this.getVoiceServerUpdate(), 59 | ]); 60 | 61 | this.sessionID = stateUpdate.session_id; 62 | this.token = serverUpdate.token; 63 | this.wsEndpoint = serverUpdate.endpoint; 64 | 65 | this.connectWS(); 66 | 67 | await new Promise((resolve, reject) => { 68 | this.resolveConnect = resolve; 69 | this.rejectConnect = reject; 70 | }); 71 | this.resolveConnect = undefined; 72 | this.rejectConnect = undefined; 73 | this.writable.uncork(); 74 | } 75 | 76 | connectWS() { 77 | this.ws = new WebSocket(`wss://${this.wsEndpoint}?v=${Voice.VERSION}&encoding=json`); 78 | this.ws.on('message', this.onWebSocketMessage.bind(this)); 79 | this.ws.onclose = (e) => { 80 | switch (e.code) { 81 | case 1000: 82 | case 4006: 83 | case 4007: 84 | case 4009: 85 | this.mode = undefined; 86 | this.connectWS(); 87 | break; 88 | case 1001: 89 | this.mode = undefined; 90 | break; 91 | case 4010: 92 | case 4011: 93 | case 4013: 94 | case 4014: 95 | throw new Error(e.reason); 96 | default: 97 | this.connectWS(); 98 | break; 99 | } 100 | }; 101 | } 102 | 103 | disconnect() { 104 | try { 105 | this.ws.close(1001); 106 | } catch { 107 | // nothing 108 | } 109 | this.ws = undefined; 110 | 111 | if (this.udpKeepAliveInterval !== undefined) { 112 | clearInterval(this.udpKeepAliveInterval); 113 | this.udpKeepAliveInterval = undefined; 114 | } 115 | 116 | try { 117 | this.udp.close(); 118 | } catch { 119 | // nothing 120 | } 121 | this.udp = undefined; 122 | } 123 | 124 | getVoiceStateUpdate() { 125 | return new Promise((resolve, reject) => { 126 | const voiceStateUpdate = (d) => { 127 | if (d.data.user_id === this.client.user.data.id) { 128 | this.client.off('VOICE_STATE_UPDATE', voiceStateUpdate); 129 | resolve(d.data); 130 | } 131 | }; 132 | this.client.on('VOICE_STATE_UPDATE', voiceStateUpdate); 133 | setTimeout(() => { 134 | this.client.off('VOICE_STATE_UPDATE', voiceStateUpdate); 135 | reject(new Error('VOICE_STATE_UPDATE timed out')); 136 | }, 10000); 137 | }); 138 | } 139 | 140 | getVoiceServerUpdate() { 141 | return new Promise((resolve, reject) => { 142 | const voiceServerUpdate = (d) => { 143 | if (d.data.guild_id === this.guildID) { 144 | this.client.off('VOICE_SERVER_UPDATE', voiceServerUpdate); 145 | resolve(d.data); 146 | } 147 | }; 148 | this.client.on('VOICE_SERVER_UPDATE', voiceServerUpdate); 149 | setTimeout(() => { 150 | this.client.off('VOICE_SERVER_UPDATE', voiceServerUpdate); 151 | reject(new Error('VOICE_SERVER_UPDATE timed out')); 152 | }, 10000); 153 | }); 154 | } 155 | 156 | sendWS(data) { 157 | this.ws.send(JSON.stringify(data)); 158 | } 159 | 160 | sendUDP(packet) { 161 | this.udp.send(packet, 0, packet.length); 162 | } 163 | 164 | onWebSocketMessage(message) { 165 | const data = JSON.parse(message); 166 | switch (data.op) { 167 | case Voice.Opcodes.HELLO: { 168 | setInterval(() => { 169 | this.sendWS({ 170 | op: Voice.Opcodes.HEARTBEAT, 171 | d: Math.floor(Math.random() * 10e10), 172 | }); 173 | }, data.d.heartbeat_interval); 174 | if (this.mode) { 175 | this.sendWS({ 176 | op: Voice.Opcodes.RESUME, 177 | d: { 178 | server_id: this.guildID, 179 | session_id: this.sessionID, 180 | token: this.token, 181 | }, 182 | }); 183 | } else { 184 | this.sendWS({ 185 | op: Voice.Opcodes.IDENTIFY, 186 | d: { 187 | server_id: this.guildID, 188 | user_id: this.client.user.data.id, 189 | session_id: this.sessionID, 190 | token: this.token, 191 | }, 192 | }); 193 | } 194 | break; 195 | } 196 | case Voice.Opcodes.READY: { 197 | this.ssrc = data.d.ssrc; 198 | 199 | this.mode = data.d.modes.find((m) => Voice.SUPPORTED_MODES.includes(m)); 200 | if (!this.mode) { 201 | this.rejectConnect(new Error('Unable to select voice mode')); 202 | return; 203 | } 204 | 205 | this.udp = createSocket('udp4'); 206 | this.udp.connect(data.d.port, data.d.ip); 207 | 208 | this.udp.once('message', (packet) => { 209 | const nil = packet.indexOf(0, 8); 210 | const address = textDecoder.decode(packet.subarray(8, nil)); 211 | const port = packet.readUInt16BE(72); 212 | 213 | this.sendWS({ 214 | op: Voice.Opcodes.SELECT_PROTOCOL, 215 | d: { 216 | protocol: 'udp', 217 | data: { 218 | address, 219 | port, 220 | mode: this.mode, 221 | }, 222 | }, 223 | }); 224 | 225 | this.udp.on('message', this.onUDPMessage.bind(this)); 226 | 227 | this.udpKeepAliveInterval = setInterval(() => { 228 | const p = Buffer.alloc(8); 229 | p.writeUIntLE(Date.now(), 0, 6); 230 | this.sendUDP(p); 231 | }, UDP_KEEP_ALIVE_INTERVAL); 232 | }); 233 | 234 | this.udp.once('connect', () => { 235 | const echo = Buffer.alloc(74); 236 | echo.writeUInt16BE(1, 0); 237 | echo.writeUInt16BE(70, 2); 238 | echo.writeUInt32BE(this.ssrc, 4); 239 | this.sendUDP(echo); 240 | }); 241 | 242 | break; 243 | } 244 | case Voice.Opcodes.SELECT_PROTOCOL_ACK: 245 | this.secretKey = Buffer.from(data.d.secret_key); 246 | this.writable.write(SILENCE_FRAME); 247 | if (this.resolveConnect) { 248 | this.resolveConnect(); 249 | } 250 | this.setSpeaking(false); 251 | break; 252 | case Voice.Opcodes.RESUMED: 253 | break; 254 | case Voice.Opcodes.SPEAKING: 255 | this.readable.connect(data.d.ssrc, data.d.user_id); 256 | break; 257 | case Voice.Opcodes.VIDEO: 258 | this.readable.connect(data.d.audio_ssrc, data.d.user_id); 259 | break; 260 | case Voice.Opcodes.CLIENT_DISCONNECT: 261 | this.readable.disconnect(data.d.audio_ssrc); 262 | break; 263 | case Voice.Opcodes.HEARTBEAT_ACK: 264 | break; 265 | default: 266 | break; 267 | } 268 | } 269 | 270 | onUDPMessage(packet) { 271 | if (packet.length === 8) { 272 | return; 273 | } 274 | 275 | this.readable.onPacket(packet); 276 | } 277 | 278 | setSpeaking(speaking) { 279 | if (speaking === this.speakingState) { 280 | return; 281 | } 282 | this.speakingState = speaking; 283 | this.sendWS({ 284 | op: Voice.Opcodes.SPEAKING, 285 | d: { 286 | speaking: speaking ? 1 : 0, 287 | delay: 0, 288 | ssrc: this.ssrc, 289 | }, 290 | }); 291 | } 292 | } 293 | 294 | module.exports = { VoiceState }; 295 | -------------------------------------------------------------------------------- /src/voice/readable.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sodium = require('sodium'); 4 | const { Readable: StreamReadable } = require('stream'); 5 | 6 | class Readable { 7 | constructor(voiceState) { 8 | this.voiceState = voiceState; 9 | 10 | this.streamMap = new Map(); 11 | this.ssrcMap = new Map(); 12 | this.nonce = Buffer.alloc(24); 13 | } 14 | 15 | decrypt(buffer) { 16 | let end; 17 | switch (this.voiceState.mode) { 18 | case 'xsalsa20_poly1305_lite': 19 | buffer.copy(this.nonce, 0, buffer.length - 4); 20 | end = buffer.length - 4; 21 | break; 22 | case 'xsalsa20_poly1305_suffix': 23 | buffer.copy(this.nonce, 0, buffer.length - 24); 24 | end = buffer.length - 24; 25 | break; 26 | case 'xsalsa20_poly1305': 27 | buffer.copy(this.nonce, 0, 0, 12); 28 | end = buffer.length; 29 | break; 30 | default: 31 | throw new RangeError(this.voiceState.mode); 32 | } 33 | 34 | const data = sodium.api.crypto_secretbox_open_easy( 35 | buffer.slice(12, end), 36 | this.nonce, 37 | this.voiceState.secretKey, 38 | ); 39 | if (!data) { 40 | throw new Error('failed to decrypt audio data'); 41 | } 42 | 43 | return data; 44 | } 45 | 46 | onPacket(packet) { 47 | const ssrc = packet.readUint32BE(8); 48 | const userID = this.ssrcMap.get(ssrc); 49 | if (!userID) { 50 | return; 51 | } 52 | const stream = this.streamMap.get(userID); 53 | if (!stream) { 54 | return; 55 | } 56 | 57 | let data = this.decrypt(packet); 58 | 59 | if (data[0] === 0xBE && data[1] === 0xDE && data.length > 4) { 60 | const headerExtensionLength = data.readUInt16BE(2); 61 | let offset = 4; 62 | for (let i = 0; i < headerExtensionLength; i += 1) { 63 | const byte = data[offset]; 64 | offset += 1; 65 | if (byte === 0) { 66 | continue; // eslint-disable-line no-continue 67 | } 68 | offset += 1 + (0b1111 & (byte >> 4)); 69 | } 70 | if (data[offset] === 0x00 || data[offset] === 0x02) { 71 | offset += 1; 72 | } 73 | 74 | data = data.slice(offset); 75 | } 76 | 77 | stream.push(data); 78 | } 79 | 80 | get(userID) { 81 | if (!this.streamMap.has(userID)) { 82 | this.streamMap.set(userID, new StreamReadable({ read() {} })); 83 | } 84 | return this.streamMap.get(userID); 85 | } 86 | 87 | connect(ssrc, userID) { 88 | this.ssrcMap.set(ssrc, userID); 89 | } 90 | 91 | disconnect(ssrc) { 92 | const userID = this.ssrcMap.get(ssrc); 93 | if (userID) { 94 | this.ssrcMap.delete(ssrc); 95 | const stream = this.streamMap.get(userID); 96 | if (stream) { 97 | this.streamMap.delete(userID); 98 | stream.push(null); 99 | } 100 | } 101 | } 102 | } 103 | 104 | module.exports = { Readable }; 105 | -------------------------------------------------------------------------------- /src/voice/writable.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const stream = require('stream'); 4 | const sodium = require('sodium'); 5 | 6 | const MAX_NONCE = 2 ** 32 - 1; 7 | 8 | const staticNonce = Buffer.alloc(24); 9 | 10 | class Writable extends stream.Writable { 11 | constructor(voiceState) { 12 | super({ 13 | highWaterMark: 12, 14 | }); 15 | 16 | this.voiceState = voiceState; 17 | 18 | this.nonceCount = 0; 19 | this.nonce = Buffer.alloc(24); 20 | 21 | this.audioSequence = 0; 22 | this.audioTimestamp = 0; 23 | this.startTime = 0; 24 | this.corkTime = 0; 25 | } 26 | 27 | _write(chunk, encoding, done) { 28 | if (!this.startTime) { 29 | this.startTime = Date.now(); 30 | } 31 | 32 | this.voiceState.setSpeaking(true); 33 | 34 | const packet = this.createPacket(chunk); 35 | this.voiceState.sendUDP(packet); 36 | 37 | const delay = (20 + (this.audioSequence * 20)) - (Date.now() - this.startTime); 38 | setTimeout(done, delay); 39 | 40 | this.audioSequence += 1; 41 | if (this.audioSequence >= 2 ** 16) { 42 | this.audioSequence = 0; 43 | } 44 | this.audioTimestamp += (48000 / 100) * 2; 45 | if (this.audioTimestamp >= 2 ** 32) { 46 | this.audioTimestamp = 0; 47 | } 48 | } 49 | 50 | createPacket(buffer) { 51 | const packetBuffer = Buffer.alloc(12); 52 | packetBuffer[0] = 0x80; 53 | packetBuffer[1] = 0x78; 54 | 55 | packetBuffer.writeUInt16BE(this.audioSequence, 2); 56 | packetBuffer.writeUInt32BE(this.audioTimestamp, 4); 57 | packetBuffer.writeUInt32BE(this.voiceState.ssrc, 8); 58 | 59 | packetBuffer.copy(staticNonce, 0, 0, 12); 60 | return Buffer.concat([packetBuffer, ...this.encrypt(buffer)]); 61 | } 62 | 63 | encrypt(buffer) { 64 | switch (this.voiceState.mode) { 65 | case 'xsalsa20_poly1305_lite': 66 | this.nonceCount += 1; 67 | if (this.nonceCount > MAX_NONCE) { 68 | this.nonceCount = 0; 69 | } 70 | this.nonce.writeUInt32BE(this.nonceCount, 0); 71 | return [ 72 | sodium.api.crypto_secretbox_easy(buffer, this.nonce, this.voiceState.secretKey), 73 | this.nonce.slice(0, 4), 74 | ]; 75 | case 'xsalsa20_poly1305_suffix': { 76 | const random = sodium.randombytes_buf(24); 77 | return [ 78 | sodium.api.crypto_secretbox_easy(buffer, random, this.voiceState.secretKey), 79 | random, 80 | ]; 81 | } 82 | case 'xsalsa20_poly1305': 83 | return [sodium.api.crypto_secretbox_easy(buffer, staticNonce, this.voiceState.secretKey)]; 84 | default: 85 | throw new RangeError(this.voiceState.mode); 86 | } 87 | } 88 | 89 | cork() { 90 | super.cork(); 91 | if (!this.corkTime) { 92 | this.corkTime = Date.now(); 93 | } 94 | } 95 | 96 | uncork() { 97 | while (this.writableCorked > 0) { 98 | super.uncork(); 99 | } 100 | this.startTime += Date.now() - this.corkTime; 101 | this.corkTime = 0; 102 | } 103 | } 104 | 105 | module.exports = { Writable }; 106 | --------------------------------------------------------------------------------