├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── docs.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── LICENSE ├── README.md ├── docgen.json ├── example ├── README.md ├── index.html ├── main.js ├── renderer.js └── style.css ├── jsdoc.json ├── package.json ├── src ├── client.js ├── constants.js ├── index.js ├── transports │ ├── index.js │ ├── ipc.js │ └── websocket.js └── util.js ├── test └── rp.js └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | extends: 'airbnb-base', 6 | parser: 'babel-eslint', 7 | parserOptions: { 8 | ecmaVersion: 2018, 9 | sourceType: 'script', 10 | }, 11 | env: { 12 | es6: true, 13 | node: true, 14 | }, 15 | overrides: [ 16 | { 17 | files: ['*.jsx'], 18 | parserOptions: { 19 | sourceType: 'module', 20 | ecmaFeatures: { jsx: true }, 21 | }, 22 | }, 23 | { 24 | files: ['*.mjs'], 25 | parserOptions: { sourceType: 'module' }, 26 | env: { 27 | node: true, 28 | }, 29 | rules: { 30 | 'no-restricted-globals': ['error', 'require'], 31 | }, 32 | }, 33 | { 34 | files: ['*.web.js'], 35 | env: { browser: true }, 36 | }, 37 | ], 38 | rules: { 39 | 'strict': ['error', 'global'], 40 | 'indent': ['error', 2, { 41 | SwitchCase: 1, 42 | FunctionDeclaration: { 43 | parameters: 'first', 44 | }, 45 | FunctionExpression: { 46 | parameters: 'first', 47 | }, 48 | CallExpression: { 49 | arguments: 'first', 50 | }, 51 | }], 52 | 'no-bitwise': 'off', 53 | 'no-iterator': 'off', 54 | 'global-require': 'off', 55 | 'quote-props': ['error', 'consistent-as-needed'], 56 | 'brace-style': ['error', '1tbs', { allowSingleLine: false }], 57 | 'curly': ['error', 'all'], 58 | 'no-param-reassign': 'off', 59 | 'arrow-parens': ['error', 'always'], 60 | 'no-multi-assign': 'off', 61 | 'no-underscore-dangle': 'off', 62 | 'no-restricted-syntax': 'off', 63 | 'object-curly-newline': 'off', 64 | 'prefer-const': ['error', { destructuring: 'all' }], 65 | 'class-methods-use-this': 'off', 66 | 'implicit-arrow-linebreak': 'off', 67 | 'lines-between-class-members': 'off', 68 | 'import/no-dynamic-require': 'off', 69 | 'import/no-extraneous-dependencies': ['error', { 70 | devDependencies: true, 71 | }], 72 | 'import/extensions': 'off', 73 | 'import/prefer-default-export': 'off', 74 | 'import/no-unresolved': 'off', 75 | }, 76 | globals: { 77 | WebAssembly: false, 78 | BigInt: false, 79 | BigInt64Array: false, 80 | BigUint64Array: false, 81 | URL: false, 82 | Atomics: false, 83 | SharedArrayBuffer: false, 84 | globalThis: false, 85 | FinalizationGroup: false, 86 | WeakRef: false, 87 | queueMicrotask: false, 88 | }, 89 | }; 90 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [devsnek] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: '15' 14 | - run: "npm install" 15 | - run: "npm run docs" 16 | - uses: JamesIves/github-pages-deploy-action@3.7.1 17 | with: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | BRANCH: docs 20 | FOLDER: docs-out 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | package-lock.json 3 | browser.js 4 | test/auth.js 5 | docs-out 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | example 3 | test 4 | package-lock.json 5 | shrinkwrap.yml 6 | docs.json 7 | docgen.json 8 | jsdoc.json 9 | webpack.config.js 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 devsnek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | Discord server 5 | NPM version 6 | NPM downloads 7 | Dependencies 8 |

9 |

10 | NPM info 11 |

12 |
13 | 14 | # Discord.js RPC Extension 15 | 16 | ### [Documentation](https://discord.js.org/#/docs/rpc/) 17 | 18 | ### [Rich Presence Example](https://github.com/discordjs/RPC/blob/master/example) 19 | 20 | ### __Browser__ Example 21 | 22 | ```javascript 23 | const clientId = '287406016902594560'; 24 | const scopes = ['rpc', 'rpc.api', 'messages.read']; 25 | 26 | const client = new RPC.Client({ transport: 'websocket' }); 27 | 28 | client.on('ready', () => { 29 | console.log('Logged in as', client.application.name); 30 | console.log('Authed for user', client.user.username); 31 | 32 | client.selectVoiceChannel('81384788862181376'); 33 | }); 34 | 35 | // Log in to RPC with client id 36 | client.login({ clientId, scopes }); 37 | ``` 38 | -------------------------------------------------------------------------------- /docgen.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "General", 4 | "files": [ 5 | { 6 | "path": "../README.md", 7 | "name": "Welcome", 8 | "id": "welcome" 9 | } 10 | ] 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | To run this example simply run `npm run example` 2 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BOOP TEH SNEK (RPC Example) 6 | 7 | 8 | 9 |

BOOP TEH SNEK

10 |
🐍
11 |

0 BOOPS

12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-disable no-console */ 4 | 5 | const { app, BrowserWindow } = require('electron'); 6 | const path = require('path'); 7 | const url = require('url'); 8 | const DiscordRPC = require('../'); 9 | 10 | let mainWindow; 11 | 12 | function createWindow() { 13 | mainWindow = new BrowserWindow({ 14 | width: 340, 15 | height: 380, 16 | resizable: false, 17 | titleBarStyle: 'hidden', 18 | webPreferences: { 19 | nodeIntegration: true, 20 | }, 21 | }); 22 | 23 | mainWindow.loadURL(url.format({ 24 | pathname: path.join(__dirname, 'index.html'), 25 | protocol: 'file:', 26 | slashes: true, 27 | })); 28 | 29 | mainWindow.on('closed', () => { 30 | mainWindow = null; 31 | }); 32 | } 33 | 34 | app.on('ready', createWindow); 35 | 36 | app.on('window-all-closed', () => { 37 | app.quit(); 38 | }); 39 | 40 | app.on('activate', () => { 41 | if (mainWindow === null) { 42 | createWindow(); 43 | } 44 | }); 45 | 46 | // Set this to your Client ID. 47 | const clientId = '280984871685062656'; 48 | 49 | // Only needed if you want to use spectate, join, or ask to join 50 | DiscordRPC.register(clientId); 51 | 52 | const rpc = new DiscordRPC.Client({ transport: 'ipc' }); 53 | const startTimestamp = new Date(); 54 | 55 | async function setActivity() { 56 | if (!rpc || !mainWindow) { 57 | return; 58 | } 59 | 60 | const boops = await mainWindow.webContents.executeJavaScript('window.boops'); 61 | 62 | // You'll need to have snek_large and snek_small assets uploaded to 63 | // https://discord.com/developers/applications//rich-presence/assets 64 | rpc.setActivity({ 65 | details: `booped ${boops} times`, 66 | state: 'in slither party', 67 | startTimestamp, 68 | largeImageKey: 'snek_large', 69 | largeImageText: 'tea is delicious', 70 | smallImageKey: 'snek_small', 71 | smallImageText: 'i am my own pillows', 72 | instance: false, 73 | }); 74 | } 75 | 76 | rpc.on('ready', () => { 77 | setActivity(); 78 | 79 | // activity can only be set every 15 seconds 80 | setInterval(() => { 81 | setActivity(); 82 | }, 15e3); 83 | }); 84 | 85 | rpc.login({ clientId }).catch(console.error); 86 | -------------------------------------------------------------------------------- /example/renderer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-env browser */ 4 | 5 | const { webFrame } = require('electron'); 6 | 7 | const snek = document.getElementById('snek'); 8 | const counter = document.getElementById('boops'); 9 | 10 | webFrame.setVisualZoomLevelLimits(1, 1); 11 | webFrame.setLayoutZoomLevelLimits(0, 0); 12 | 13 | window.boops = 0; 14 | function boop() { 15 | window.boops += 1; 16 | counter.innerHTML = `${window.boops} BOOPS`; 17 | } 18 | 19 | snek.onmousedown = () => { 20 | snek.style['font-size'] = '550%'; 21 | boop(); 22 | }; 23 | 24 | snek.onmouseup = () => { 25 | snek.style['font-size'] = '500%'; 26 | }; 27 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | margin: 0px; 3 | height: 100%; 4 | cursor: default; 5 | font-family: sans-serif; 6 | -webkit-touch-callout: none; 7 | -webkit-user-select: none; 8 | -khtml-user-select: none; 9 | -moz-user-select: none; 10 | -ms-user-select: none; 11 | user-select: none; 12 | -webkit-app-region: drag; 13 | } 14 | 15 | h1 { 16 | text-align: center; 17 | margin: auto; 18 | padding-top: 1em; 19 | } 20 | 21 | #game { 22 | height: 60%; 23 | display: grid; 24 | -webkit-app-region: no-drag; 25 | } 26 | 27 | #snek { 28 | cursor: pointer; 29 | font-size: 500%; 30 | margin: auto; 31 | } 32 | 33 | #boops { 34 | text-align: center; 35 | -webkit-app-region: no-drag; 36 | } 37 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["node_modules/jsdoc-strip-async-await"] 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-rpc", 3 | "version": "4.0.1", 4 | "description": "A simple RPC client for Discord", 5 | "keywords": [ 6 | "discord", 7 | "rpc", 8 | "rich presence", 9 | "remote procedural call" 10 | ], 11 | "main": "src/index.js", 12 | "jsdelivr": "browser.js", 13 | "unpkg": "browser.js", 14 | "author": "snek ", 15 | "license": "MIT", 16 | "homepage": "https://github.com/discordjs/RPC#readme", 17 | "bugs": { 18 | "url": "https://github.com/discordjs/RPC/issues" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/discordjs/RPC.git" 23 | }, 24 | "scripts": { 25 | "lint": "eslint src test --ext=js", 26 | "docs": "mkdir -p docs-out && docgen --source src --output docs-out/master.json --jsdoc jsdoc.json --custom docgen.json", 27 | "example": "electron example/main.js", 28 | "build:browser": "webpack-cli", 29 | "prepublishOnly": "npm run lint && npm run build:browser" 30 | }, 31 | "dependencies": { 32 | "node-fetch": "^2.6.1", 33 | "ws": "^7.3.1" 34 | }, 35 | "optionalDependencies": { 36 | "register-scheme": "github:devsnek/node-register-scheme" 37 | }, 38 | "devDependencies": { 39 | "babel-eslint": "^10.0.3", 40 | "discord.js-docgen": "github:discordjs/docgen", 41 | "electron": "^7.1.9", 42 | "eslint": "^6.1.0", 43 | "eslint-config-airbnb-base": "14.0.0", 44 | "eslint-plugin-import": "^2.18.2", 45 | "jsdoc-strip-async-await": "^0.1.0", 46 | "webpack": "^4.40.0", 47 | "webpack-cli": "^3.3.8" 48 | }, 49 | "browser": { 50 | "net": false, 51 | "ws": false, 52 | "uws": false, 53 | "erlpack": false, 54 | "electron": false, 55 | "register-scheme": false, 56 | "./src/transports/IPC.js": false 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events'); 4 | const { setTimeout, clearTimeout } = require('timers'); 5 | const fetch = require('node-fetch'); 6 | const transports = require('./transports'); 7 | const { RPCCommands, RPCEvents, RelationshipTypes } = require('./constants'); 8 | const { pid: getPid, uuid } = require('./util'); 9 | 10 | function subKey(event, args) { 11 | return `${event}${JSON.stringify(args)}`; 12 | } 13 | 14 | /** 15 | * @typedef {RPCClientOptions} 16 | * @extends {ClientOptions} 17 | * @prop {string} transport RPC transport. one of `ipc` or `websocket` 18 | */ 19 | 20 | /** 21 | * The main hub for interacting with Discord RPC 22 | * @extends {BaseClient} 23 | */ 24 | class RPCClient extends EventEmitter { 25 | /** 26 | * @param {RPCClientOptions} [options] Options for the client. 27 | * You must provide a transport 28 | */ 29 | constructor(options = {}) { 30 | super(); 31 | 32 | this.options = options; 33 | 34 | this.accessToken = null; 35 | this.clientId = null; 36 | 37 | /** 38 | * Application used in this client 39 | * @type {?ClientApplication} 40 | */ 41 | this.application = null; 42 | 43 | /** 44 | * User used in this application 45 | * @type {?User} 46 | */ 47 | this.user = null; 48 | 49 | const Transport = transports[options.transport]; 50 | if (!Transport) { 51 | throw new TypeError('RPC_INVALID_TRANSPORT', options.transport); 52 | } 53 | 54 | this.fetch = (method, path, { data, query } = {}) => 55 | fetch(`${this.fetch.endpoint}${path}${query ? new URLSearchParams(query) : ''}`, { 56 | method, 57 | body: data, 58 | headers: { 59 | Authorization: `Bearer ${this.accessToken}`, 60 | }, 61 | }).then(async (r) => { 62 | const body = await r.json(); 63 | if (!r.ok) { 64 | const e = new Error(r.status); 65 | e.body = body; 66 | throw e; 67 | } 68 | return body; 69 | }); 70 | 71 | this.fetch.endpoint = 'https://discord.com/api'; 72 | 73 | /** 74 | * Raw transport userd 75 | * @type {RPCTransport} 76 | * @private 77 | */ 78 | this.transport = new Transport(this); 79 | this.transport.on('message', this._onRpcMessage.bind(this)); 80 | 81 | /** 82 | * Map of nonces being expected from the transport 83 | * @type {Map} 84 | * @private 85 | */ 86 | this._expecting = new Map(); 87 | 88 | this._connectPromise = undefined; 89 | } 90 | 91 | /** 92 | * Search and connect to RPC 93 | */ 94 | connect(clientId) { 95 | if (this._connectPromise) { 96 | return this._connectPromise; 97 | } 98 | this._connectPromise = new Promise((resolve, reject) => { 99 | this.clientId = clientId; 100 | const timeout = setTimeout(() => reject(new Error('RPC_CONNECTION_TIMEOUT')), 10e3); 101 | timeout.unref(); 102 | this.once('connected', () => { 103 | clearTimeout(timeout); 104 | resolve(this); 105 | }); 106 | this.transport.once('close', () => { 107 | this._expecting.forEach((e) => { 108 | e.reject(new Error('connection closed')); 109 | }); 110 | this.emit('disconnected'); 111 | reject(new Error('connection closed')); 112 | }); 113 | this.transport.connect().catch(reject); 114 | }); 115 | return this._connectPromise; 116 | } 117 | 118 | /** 119 | * @typedef {RPCLoginOptions} 120 | * @param {string} clientId Client ID 121 | * @param {string} [clientSecret] Client secret 122 | * @param {string} [accessToken] Access token 123 | * @param {string} [rpcToken] RPC token 124 | * @param {string} [tokenEndpoint] Token endpoint 125 | * @param {string[]} [scopes] Scopes to authorize with 126 | */ 127 | 128 | /** 129 | * Performs authentication flow. Automatically calls Client#connect if needed. 130 | * @param {RPCLoginOptions} options Options for authentication. 131 | * At least one property must be provided to perform login. 132 | * @example client.login({ clientId: '1234567', clientSecret: 'abcdef123' }); 133 | * @returns {Promise} 134 | */ 135 | async login(options = {}) { 136 | let { clientId, accessToken } = options; 137 | await this.connect(clientId); 138 | if (!options.scopes) { 139 | this.emit('ready'); 140 | return this; 141 | } 142 | if (!accessToken) { 143 | accessToken = await this.authorize(options); 144 | } 145 | return this.authenticate(accessToken); 146 | } 147 | 148 | /** 149 | * Request 150 | * @param {string} cmd Command 151 | * @param {Object} [args={}] Arguments 152 | * @param {string} [evt] Event 153 | * @returns {Promise} 154 | * @private 155 | */ 156 | request(cmd, args, evt) { 157 | return new Promise((resolve, reject) => { 158 | const nonce = uuid(); 159 | this.transport.send({ cmd, args, evt, nonce }); 160 | this._expecting.set(nonce, { resolve, reject }); 161 | }); 162 | } 163 | 164 | /** 165 | * Message handler 166 | * @param {Object} message message 167 | * @private 168 | */ 169 | _onRpcMessage(message) { 170 | if (message.cmd === RPCCommands.DISPATCH && message.evt === RPCEvents.READY) { 171 | if (message.data.user) { 172 | this.user = message.data.user; 173 | } 174 | this.emit('connected'); 175 | } else if (this._expecting.has(message.nonce)) { 176 | const { resolve, reject } = this._expecting.get(message.nonce); 177 | if (message.evt === 'ERROR') { 178 | const e = new Error(message.data.message); 179 | e.code = message.data.code; 180 | e.data = message.data; 181 | reject(e); 182 | } else { 183 | resolve(message.data); 184 | } 185 | this._expecting.delete(message.nonce); 186 | } else { 187 | this.emit(message.evt, message.data); 188 | } 189 | } 190 | 191 | /** 192 | * Authorize 193 | * @param {Object} options options 194 | * @returns {Promise} 195 | * @private 196 | */ 197 | async authorize({ scopes, clientSecret, rpcToken, redirectUri, prompt } = {}) { 198 | if (clientSecret && rpcToken === true) { 199 | const body = await this.fetch('POST', '/oauth2/token/rpc', { 200 | data: new URLSearchParams({ 201 | client_id: this.clientId, 202 | client_secret: clientSecret, 203 | }), 204 | }); 205 | rpcToken = body.rpc_token; 206 | } 207 | 208 | const { code } = await this.request('AUTHORIZE', { 209 | scopes, 210 | client_id: this.clientId, 211 | prompt, 212 | rpc_token: rpcToken, 213 | }); 214 | 215 | const response = await this.fetch('POST', '/oauth2/token', { 216 | data: new URLSearchParams({ 217 | client_id: this.clientId, 218 | client_secret: clientSecret, 219 | code, 220 | grant_type: 'authorization_code', 221 | redirect_uri: redirectUri, 222 | }), 223 | }); 224 | 225 | return response.access_token; 226 | } 227 | 228 | /** 229 | * Authenticate 230 | * @param {string} accessToken access token 231 | * @returns {Promise} 232 | * @private 233 | */ 234 | authenticate(accessToken) { 235 | return this.request('AUTHENTICATE', { access_token: accessToken }) 236 | .then(({ application, user }) => { 237 | this.accessToken = accessToken; 238 | this.application = application; 239 | this.user = user; 240 | this.emit('ready'); 241 | return this; 242 | }); 243 | } 244 | 245 | 246 | /** 247 | * Fetch a guild 248 | * @param {Snowflake} id Guild ID 249 | * @param {number} [timeout] Timeout request 250 | * @returns {Promise} 251 | */ 252 | getGuild(id, timeout) { 253 | return this.request(RPCCommands.GET_GUILD, { guild_id: id, timeout }); 254 | } 255 | 256 | /** 257 | * Fetch all guilds 258 | * @param {number} [timeout] Timeout request 259 | * @returns {Promise>} 260 | */ 261 | getGuilds(timeout) { 262 | return this.request(RPCCommands.GET_GUILDS, { timeout }); 263 | } 264 | 265 | /** 266 | * Get a channel 267 | * @param {Snowflake} id Channel ID 268 | * @param {number} [timeout] Timeout request 269 | * @returns {Promise} 270 | */ 271 | getChannel(id, timeout) { 272 | return this.request(RPCCommands.GET_CHANNEL, { channel_id: id, timeout }); 273 | } 274 | 275 | /** 276 | * Get all channels 277 | * @param {Snowflake} [id] Guild ID 278 | * @param {number} [timeout] Timeout request 279 | * @returns {Promise>} 280 | */ 281 | async getChannels(id, timeout) { 282 | const { channels } = await this.request(RPCCommands.GET_CHANNELS, { 283 | timeout, 284 | guild_id: id, 285 | }); 286 | return channels; 287 | } 288 | 289 | /** 290 | * @typedef {CertifiedDevice} 291 | * @prop {string} type One of `AUDIO_INPUT`, `AUDIO_OUTPUT`, `VIDEO_INPUT` 292 | * @prop {string} uuid This device's Windows UUID 293 | * @prop {object} vendor Vendor information 294 | * @prop {string} vendor.name Vendor's name 295 | * @prop {string} vendor.url Vendor's url 296 | * @prop {object} model Model information 297 | * @prop {string} model.name Model's name 298 | * @prop {string} model.url Model's url 299 | * @prop {string[]} related Array of related product's Windows UUIDs 300 | * @prop {boolean} echoCancellation If the device has echo cancellation 301 | * @prop {boolean} noiseSuppression If the device has noise suppression 302 | * @prop {boolean} automaticGainControl If the device has automatic gain control 303 | * @prop {boolean} hardwareMute If the device has a hardware mute 304 | */ 305 | 306 | /** 307 | * Tell discord which devices are certified 308 | * @param {CertifiedDevice[]} devices Certified devices to send to discord 309 | * @returns {Promise} 310 | */ 311 | setCertifiedDevices(devices) { 312 | return this.request(RPCCommands.SET_CERTIFIED_DEVICES, { 313 | devices: devices.map((d) => ({ 314 | type: d.type, 315 | id: d.uuid, 316 | vendor: d.vendor, 317 | model: d.model, 318 | related: d.related, 319 | echo_cancellation: d.echoCancellation, 320 | noise_suppression: d.noiseSuppression, 321 | automatic_gain_control: d.automaticGainControl, 322 | hardware_mute: d.hardwareMute, 323 | })), 324 | }); 325 | } 326 | 327 | /** 328 | * @typedef {UserVoiceSettings} 329 | * @prop {Snowflake} id ID of the user these settings apply to 330 | * @prop {?Object} [pan] Pan settings, an object with `left` and `right` set between 331 | * 0.0 and 1.0, inclusive 332 | * @prop {?number} [volume=100] The volume 333 | * @prop {bool} [mute] If the user is muted 334 | */ 335 | 336 | /** 337 | * Set the voice settings for a user, by id 338 | * @param {Snowflake} id ID of the user to set 339 | * @param {UserVoiceSettings} settings Settings 340 | * @returns {Promise} 341 | */ 342 | setUserVoiceSettings(id, settings) { 343 | return this.request(RPCCommands.SET_USER_VOICE_SETTINGS, { 344 | user_id: id, 345 | pan: settings.pan, 346 | mute: settings.mute, 347 | volume: settings.volume, 348 | }); 349 | } 350 | 351 | /** 352 | * Move the user to a voice channel 353 | * @param {Snowflake} id ID of the voice channel 354 | * @param {Object} [options] Options 355 | * @param {number} [options.timeout] Timeout for the command 356 | * @param {boolean} [options.force] Force this move. This should only be done if you 357 | * have explicit permission from the user. 358 | * @returns {Promise} 359 | */ 360 | selectVoiceChannel(id, { timeout, force = false } = {}) { 361 | return this.request(RPCCommands.SELECT_VOICE_CHANNEL, { channel_id: id, timeout, force }); 362 | } 363 | 364 | /** 365 | * Move the user to a text channel 366 | * @param {Snowflake} id ID of the voice channel 367 | * @param {Object} [options] Options 368 | * @param {number} [options.timeout] Timeout for the command 369 | * have explicit permission from the user. 370 | * @returns {Promise} 371 | */ 372 | selectTextChannel(id, { timeout } = {}) { 373 | return this.request(RPCCommands.SELECT_TEXT_CHANNEL, { channel_id: id, timeout }); 374 | } 375 | 376 | /** 377 | * Get current voice settings 378 | * @returns {Promise} 379 | */ 380 | getVoiceSettings() { 381 | return this.request(RPCCommands.GET_VOICE_SETTINGS) 382 | .then((s) => ({ 383 | automaticGainControl: s.automatic_gain_control, 384 | echoCancellation: s.echo_cancellation, 385 | noiseSuppression: s.noise_suppression, 386 | qos: s.qos, 387 | silenceWarning: s.silence_warning, 388 | deaf: s.deaf, 389 | mute: s.mute, 390 | input: { 391 | availableDevices: s.input.available_devices, 392 | device: s.input.device_id, 393 | volume: s.input.volume, 394 | }, 395 | output: { 396 | availableDevices: s.output.available_devices, 397 | device: s.output.device_id, 398 | volume: s.output.volume, 399 | }, 400 | mode: { 401 | type: s.mode.type, 402 | autoThreshold: s.mode.auto_threshold, 403 | threshold: s.mode.threshold, 404 | shortcut: s.mode.shortcut, 405 | delay: s.mode.delay, 406 | }, 407 | })); 408 | } 409 | 410 | /** 411 | * Set current voice settings, overriding the current settings until this session disconnects. 412 | * This also locks the settings for any other rpc sessions which may be connected. 413 | * @param {Object} args Settings 414 | * @returns {Promise} 415 | */ 416 | setVoiceSettings(args) { 417 | return this.request(RPCCommands.SET_VOICE_SETTINGS, { 418 | automatic_gain_control: args.automaticGainControl, 419 | echo_cancellation: args.echoCancellation, 420 | noise_suppression: args.noiseSuppression, 421 | qos: args.qos, 422 | silence_warning: args.silenceWarning, 423 | deaf: args.deaf, 424 | mute: args.mute, 425 | input: args.input ? { 426 | device_id: args.input.device, 427 | volume: args.input.volume, 428 | } : undefined, 429 | output: args.output ? { 430 | device_id: args.output.device, 431 | volume: args.output.volume, 432 | } : undefined, 433 | mode: args.mode ? { 434 | type: args.mode.type, 435 | auto_threshold: args.mode.autoThreshold, 436 | threshold: args.mode.threshold, 437 | shortcut: args.mode.shortcut, 438 | delay: args.mode.delay, 439 | } : undefined, 440 | }); 441 | } 442 | 443 | /** 444 | * Capture a shortcut using the client 445 | * The callback takes (key, stop) where `stop` is a function that will stop capturing. 446 | * This `stop` function must be called before disconnecting or else the user will have 447 | * to restart their client. 448 | * @param {Function} callback Callback handling keys 449 | * @returns {Promise} 450 | */ 451 | captureShortcut(callback) { 452 | const subid = subKey(RPCEvents.CAPTURE_SHORTCUT_CHANGE); 453 | const stop = () => { 454 | this._subscriptions.delete(subid); 455 | return this.request(RPCCommands.CAPTURE_SHORTCUT, { action: 'STOP' }); 456 | }; 457 | this._subscriptions.set(subid, ({ shortcut }) => { 458 | callback(shortcut, stop); 459 | }); 460 | return this.request(RPCCommands.CAPTURE_SHORTCUT, { action: 'START' }) 461 | .then(() => stop); 462 | } 463 | 464 | /** 465 | * Sets the presence for the logged in user. 466 | * @param {object} args The rich presence to pass. 467 | * @param {number} [pid] The application's process ID. Defaults to the executing process' PID. 468 | * @returns {Promise} 469 | */ 470 | setActivity(args = {}, pid = getPid()) { 471 | let timestamps; 472 | let assets; 473 | let party; 474 | let secrets; 475 | if (args.startTimestamp || args.endTimestamp) { 476 | timestamps = { 477 | start: args.startTimestamp, 478 | end: args.endTimestamp, 479 | }; 480 | if (timestamps.start instanceof Date) { 481 | timestamps.start = Math.round(timestamps.start.getTime()); 482 | } 483 | if (timestamps.end instanceof Date) { 484 | timestamps.end = Math.round(timestamps.end.getTime()); 485 | } 486 | if (timestamps.start > 2147483647000) { 487 | throw new RangeError('timestamps.start must fit into a unix timestamp'); 488 | } 489 | if (timestamps.end > 2147483647000) { 490 | throw new RangeError('timestamps.end must fit into a unix timestamp'); 491 | } 492 | } 493 | if ( 494 | args.largeImageKey || args.largeImageText 495 | || args.smallImageKey || args.smallImageText 496 | ) { 497 | assets = { 498 | large_image: args.largeImageKey, 499 | large_text: args.largeImageText, 500 | small_image: args.smallImageKey, 501 | small_text: args.smallImageText, 502 | }; 503 | } 504 | if (args.partySize || args.partyId || args.partyMax) { 505 | party = { id: args.partyId }; 506 | if (args.partySize || args.partyMax) { 507 | party.size = [args.partySize, args.partyMax]; 508 | } 509 | } 510 | if (args.matchSecret || args.joinSecret || args.spectateSecret) { 511 | secrets = { 512 | match: args.matchSecret, 513 | join: args.joinSecret, 514 | spectate: args.spectateSecret, 515 | }; 516 | } 517 | 518 | return this.request(RPCCommands.SET_ACTIVITY, { 519 | pid, 520 | activity: { 521 | state: args.state, 522 | details: args.details, 523 | timestamps, 524 | assets, 525 | party, 526 | secrets, 527 | buttons: args.buttons, 528 | instance: !!args.instance, 529 | }, 530 | }); 531 | } 532 | 533 | /** 534 | * Clears the currently set presence, if any. This will hide the "Playing X" message 535 | * displayed below the user's name. 536 | * @param {number} [pid] The application's process ID. Defaults to the executing process' PID. 537 | * @returns {Promise} 538 | */ 539 | clearActivity(pid = getPid()) { 540 | return this.request(RPCCommands.SET_ACTIVITY, { 541 | pid, 542 | }); 543 | } 544 | 545 | /** 546 | * Invite a user to join the game the RPC user is currently playing 547 | * @param {User} user The user to invite 548 | * @returns {Promise} 549 | */ 550 | sendJoinInvite(user) { 551 | return this.request(RPCCommands.SEND_ACTIVITY_JOIN_INVITE, { 552 | user_id: user.id || user, 553 | }); 554 | } 555 | 556 | /** 557 | * Request to join the game the user is playing 558 | * @param {User} user The user whose game you want to request to join 559 | * @returns {Promise} 560 | */ 561 | sendJoinRequest(user) { 562 | return this.request(RPCCommands.SEND_ACTIVITY_JOIN_REQUEST, { 563 | user_id: user.id || user, 564 | }); 565 | } 566 | 567 | /** 568 | * Reject a join request from a user 569 | * @param {User} user The user whose request you wish to reject 570 | * @returns {Promise} 571 | */ 572 | closeJoinRequest(user) { 573 | return this.request(RPCCommands.CLOSE_ACTIVITY_JOIN_REQUEST, { 574 | user_id: user.id || user, 575 | }); 576 | } 577 | 578 | createLobby(type, capacity, metadata) { 579 | return this.request(RPCCommands.CREATE_LOBBY, { 580 | type, 581 | capacity, 582 | metadata, 583 | }); 584 | } 585 | 586 | updateLobby(lobby, { type, owner, capacity, metadata } = {}) { 587 | return this.request(RPCCommands.UPDATE_LOBBY, { 588 | id: lobby.id || lobby, 589 | type, 590 | owner_id: (owner && owner.id) || owner, 591 | capacity, 592 | metadata, 593 | }); 594 | } 595 | 596 | deleteLobby(lobby) { 597 | return this.request(RPCCommands.DELETE_LOBBY, { 598 | id: lobby.id || lobby, 599 | }); 600 | } 601 | 602 | connectToLobby(id, secret) { 603 | return this.request(RPCCommands.CONNECT_TO_LOBBY, { 604 | id, 605 | secret, 606 | }); 607 | } 608 | 609 | sendToLobby(lobby, data) { 610 | return this.request(RPCCommands.SEND_TO_LOBBY, { 611 | id: lobby.id || lobby, 612 | data, 613 | }); 614 | } 615 | 616 | disconnectFromLobby(lobby) { 617 | return this.request(RPCCommands.DISCONNECT_FROM_LOBBY, { 618 | id: lobby.id || lobby, 619 | }); 620 | } 621 | 622 | updateLobbyMember(lobby, user, metadata) { 623 | return this.request(RPCCommands.UPDATE_LOBBY_MEMBER, { 624 | lobby_id: lobby.id || lobby, 625 | user_id: user.id || user, 626 | metadata, 627 | }); 628 | } 629 | 630 | getRelationships() { 631 | const types = Object.keys(RelationshipTypes); 632 | return this.request(RPCCommands.GET_RELATIONSHIPS) 633 | .then((o) => o.relationships.map((r) => ({ 634 | ...r, 635 | type: types[r.type], 636 | }))); 637 | } 638 | 639 | /** 640 | * Subscribe to an event 641 | * @param {string} event Name of event e.g. `MESSAGE_CREATE` 642 | * @param {Object} [args] Args for event e.g. `{ channel_id: '1234' }` 643 | * @returns {Promise} 644 | */ 645 | async subscribe(event, args) { 646 | await this.request(RPCCommands.SUBSCRIBE, args, event); 647 | return { 648 | unsubscribe: () => this.request(RPCCommands.UNSUBSCRIBE, args, event), 649 | }; 650 | } 651 | 652 | /** 653 | * Destroy the client 654 | */ 655 | async destroy() { 656 | await this.transport.close(); 657 | } 658 | } 659 | 660 | module.exports = RPCClient; 661 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function keyMirror(arr) { 4 | const tmp = {}; 5 | for (const value of arr) { 6 | tmp[value] = value; 7 | } 8 | return tmp; 9 | } 10 | 11 | 12 | exports.browser = typeof window !== 'undefined'; 13 | 14 | exports.RPCCommands = keyMirror([ 15 | 'DISPATCH', 16 | 'AUTHORIZE', 17 | 'AUTHENTICATE', 18 | 'GET_GUILD', 19 | 'GET_GUILDS', 20 | 'GET_CHANNEL', 21 | 'GET_CHANNELS', 22 | 'CREATE_CHANNEL_INVITE', 23 | 'GET_RELATIONSHIPS', 24 | 'GET_USER', 25 | 'SUBSCRIBE', 26 | 'UNSUBSCRIBE', 27 | 'SET_USER_VOICE_SETTINGS', 28 | 'SET_USER_VOICE_SETTINGS_2', 29 | 'SELECT_VOICE_CHANNEL', 30 | 'GET_SELECTED_VOICE_CHANNEL', 31 | 'SELECT_TEXT_CHANNEL', 32 | 'GET_VOICE_SETTINGS', 33 | 'SET_VOICE_SETTINGS_2', 34 | 'SET_VOICE_SETTINGS', 35 | 'CAPTURE_SHORTCUT', 36 | 'SET_ACTIVITY', 37 | 'SEND_ACTIVITY_JOIN_INVITE', 38 | 'CLOSE_ACTIVITY_JOIN_REQUEST', 39 | 'ACTIVITY_INVITE_USER', 40 | 'ACCEPT_ACTIVITY_INVITE', 41 | 'INVITE_BROWSER', 42 | 'DEEP_LINK', 43 | 'CONNECTIONS_CALLBACK', 44 | 'BRAINTREE_POPUP_BRIDGE_CALLBACK', 45 | 'GIFT_CODE_BROWSER', 46 | 'GUILD_TEMPLATE_BROWSER', 47 | 'OVERLAY', 48 | 'BROWSER_HANDOFF', 49 | 'SET_CERTIFIED_DEVICES', 50 | 'GET_IMAGE', 51 | 'CREATE_LOBBY', 52 | 'UPDATE_LOBBY', 53 | 'DELETE_LOBBY', 54 | 'UPDATE_LOBBY_MEMBER', 55 | 'CONNECT_TO_LOBBY', 56 | 'DISCONNECT_FROM_LOBBY', 57 | 'SEND_TO_LOBBY', 58 | 'SEARCH_LOBBIES', 59 | 'CONNECT_TO_LOBBY_VOICE', 60 | 'DISCONNECT_FROM_LOBBY_VOICE', 61 | 'SET_OVERLAY_LOCKED', 62 | 'OPEN_OVERLAY_ACTIVITY_INVITE', 63 | 'OPEN_OVERLAY_GUILD_INVITE', 64 | 'OPEN_OVERLAY_VOICE_SETTINGS', 65 | 'VALIDATE_APPLICATION', 66 | 'GET_ENTITLEMENT_TICKET', 67 | 'GET_APPLICATION_TICKET', 68 | 'START_PURCHASE', 69 | 'GET_SKUS', 70 | 'GET_ENTITLEMENTS', 71 | 'GET_NETWORKING_CONFIG', 72 | 'NETWORKING_SYSTEM_METRICS', 73 | 'NETWORKING_PEER_METRICS', 74 | 'NETWORKING_CREATE_TOKEN', 75 | 'SET_USER_ACHIEVEMENT', 76 | 'GET_USER_ACHIEVEMENTS', 77 | ]); 78 | 79 | exports.RPCEvents = keyMirror([ 80 | 'CURRENT_USER_UPDATE', 81 | 'GUILD_STATUS', 82 | 'GUILD_CREATE', 83 | 'CHANNEL_CREATE', 84 | 'RELATIONSHIP_UPDATE', 85 | 'VOICE_CHANNEL_SELECT', 86 | 'VOICE_STATE_CREATE', 87 | 'VOICE_STATE_DELETE', 88 | 'VOICE_STATE_UPDATE', 89 | 'VOICE_SETTINGS_UPDATE', 90 | 'VOICE_SETTINGS_UPDATE_2', 91 | 'VOICE_CONNECTION_STATUS', 92 | 'SPEAKING_START', 93 | 'SPEAKING_STOP', 94 | 'GAME_JOIN', 95 | 'GAME_SPECTATE', 96 | 'ACTIVITY_JOIN', 97 | 'ACTIVITY_JOIN_REQUEST', 98 | 'ACTIVITY_SPECTATE', 99 | 'ACTIVITY_INVITE', 100 | 'NOTIFICATION_CREATE', 101 | 'MESSAGE_CREATE', 102 | 'MESSAGE_UPDATE', 103 | 'MESSAGE_DELETE', 104 | 'LOBBY_DELETE', 105 | 'LOBBY_UPDATE', 106 | 'LOBBY_MEMBER_CONNECT', 107 | 'LOBBY_MEMBER_DISCONNECT', 108 | 'LOBBY_MEMBER_UPDATE', 109 | 'LOBBY_MESSAGE', 110 | 'CAPTURE_SHORTCUT_CHANGE', 111 | 'OVERLAY', 112 | 'OVERLAY_UPDATE', 113 | 'ENTITLEMENT_CREATE', 114 | 'ENTITLEMENT_DELETE', 115 | 'USER_ACHIEVEMENT_UPDATE', 116 | 'READY', 117 | 'ERROR', 118 | ]); 119 | 120 | exports.RPCErrors = { 121 | CAPTURE_SHORTCUT_ALREADY_LISTENING: 5004, 122 | GET_GUILD_TIMED_OUT: 5002, 123 | INVALID_ACTIVITY_JOIN_REQUEST: 4012, 124 | INVALID_ACTIVITY_SECRET: 5005, 125 | INVALID_CHANNEL: 4005, 126 | INVALID_CLIENTID: 4007, 127 | INVALID_COMMAND: 4002, 128 | INVALID_ENTITLEMENT: 4015, 129 | INVALID_EVENT: 4004, 130 | INVALID_GIFT_CODE: 4016, 131 | INVALID_GUILD: 4003, 132 | INVALID_INVITE: 4011, 133 | INVALID_LOBBY: 4013, 134 | INVALID_LOBBY_SECRET: 4014, 135 | INVALID_ORIGIN: 4008, 136 | INVALID_PAYLOAD: 4000, 137 | INVALID_PERMISSIONS: 4006, 138 | INVALID_TOKEN: 4009, 139 | INVALID_USER: 4010, 140 | LOBBY_FULL: 5007, 141 | NO_ELIGIBLE_ACTIVITY: 5006, 142 | OAUTH2_ERROR: 5000, 143 | PURCHASE_CANCELED: 5008, 144 | PURCHASE_ERROR: 5009, 145 | RATE_LIMITED: 5011, 146 | SELECT_CHANNEL_TIMED_OUT: 5001, 147 | SELECT_VOICE_FORCE_REQUIRED: 5003, 148 | SERVICE_UNAVAILABLE: 1001, 149 | TRANSACTION_ABORTED: 1002, 150 | UNAUTHORIZED_FOR_ACHIEVEMENT: 5010, 151 | UNKNOWN_ERROR: 1000, 152 | }; 153 | 154 | exports.RPCCloseCodes = { 155 | CLOSE_NORMAL: 1000, 156 | CLOSE_UNSUPPORTED: 1003, 157 | CLOSE_ABNORMAL: 1006, 158 | INVALID_CLIENTID: 4000, 159 | INVALID_ORIGIN: 4001, 160 | RATELIMITED: 4002, 161 | TOKEN_REVOKED: 4003, 162 | INVALID_VERSION: 4004, 163 | INVALID_ENCODING: 4005, 164 | }; 165 | 166 | exports.LobbyTypes = { 167 | PRIVATE: 1, 168 | PUBLIC: 2, 169 | }; 170 | 171 | exports.RelationshipTypes = { 172 | NONE: 0, 173 | FRIEND: 1, 174 | BLOCKED: 2, 175 | PENDING_INCOMING: 3, 176 | PENDING_OUTGOING: 4, 177 | IMPLICIT: 5, 178 | }; 179 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('./util'); 4 | 5 | module.exports = { 6 | Client: require('./client'), 7 | register(id) { 8 | return util.register(`discord-${id}`); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/transports/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | ipc: require('./ipc'), 5 | websocket: require('./websocket'), 6 | }; 7 | -------------------------------------------------------------------------------- /src/transports/ipc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const net = require('net'); 4 | const EventEmitter = require('events'); 5 | const fetch = require('node-fetch'); 6 | const { uuid } = require('../util'); 7 | 8 | const OPCodes = { 9 | HANDSHAKE: 0, 10 | FRAME: 1, 11 | CLOSE: 2, 12 | PING: 3, 13 | PONG: 4, 14 | }; 15 | 16 | function getIPCPath(id) { 17 | if (process.platform === 'win32') { 18 | return `\\\\?\\pipe\\discord-ipc-${id}`; 19 | } 20 | const { env: { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP } } = process; 21 | const prefix = XDG_RUNTIME_DIR || TMPDIR || TMP || TEMP || '/tmp'; 22 | return `${prefix.replace(/\/$/, '')}/discord-ipc-${id}`; 23 | } 24 | 25 | function getIPC(id = 0) { 26 | return new Promise((resolve, reject) => { 27 | const path = getIPCPath(id); 28 | const onerror = () => { 29 | if (id < 10) { 30 | resolve(getIPC(id + 1)); 31 | } else { 32 | reject(new Error('Could not connect')); 33 | } 34 | }; 35 | const sock = net.createConnection(path, () => { 36 | sock.removeListener('error', onerror); 37 | resolve(sock); 38 | }); 39 | sock.once('error', onerror); 40 | }); 41 | } 42 | 43 | async function findEndpoint(tries = 0) { 44 | if (tries > 30) { 45 | throw new Error('Could not find endpoint'); 46 | } 47 | const endpoint = `http://127.0.0.1:${6463 + (tries % 10)}`; 48 | try { 49 | const r = await fetch(endpoint); 50 | if (r.status === 404) { 51 | return endpoint; 52 | } 53 | return findEndpoint(tries + 1); 54 | } catch (e) { 55 | return findEndpoint(tries + 1); 56 | } 57 | } 58 | 59 | function encode(op, data) { 60 | data = JSON.stringify(data); 61 | const len = Buffer.byteLength(data); 62 | const packet = Buffer.alloc(8 + len); 63 | packet.writeInt32LE(op, 0); 64 | packet.writeInt32LE(len, 4); 65 | packet.write(data, 8, len); 66 | return packet; 67 | } 68 | 69 | const working = { 70 | full: '', 71 | op: undefined, 72 | }; 73 | 74 | function decode(socket, callback) { 75 | const packet = socket.read(); 76 | if (!packet) { 77 | return; 78 | } 79 | 80 | let { op } = working; 81 | let raw; 82 | if (working.full === '') { 83 | op = working.op = packet.readInt32LE(0); 84 | const len = packet.readInt32LE(4); 85 | raw = packet.slice(8, len + 8); 86 | } else { 87 | raw = packet.toString(); 88 | } 89 | 90 | try { 91 | const data = JSON.parse(working.full + raw); 92 | callback({ op, data }); // eslint-disable-line callback-return 93 | working.full = ''; 94 | working.op = undefined; 95 | } catch (err) { 96 | working.full += raw; 97 | } 98 | 99 | decode(socket, callback); 100 | } 101 | 102 | class IPCTransport extends EventEmitter { 103 | constructor(client) { 104 | super(); 105 | this.client = client; 106 | this.socket = null; 107 | } 108 | 109 | async connect() { 110 | const socket = this.socket = await getIPC(); 111 | socket.on('close', this.onClose.bind(this)); 112 | socket.on('error', this.onClose.bind(this)); 113 | this.emit('open'); 114 | socket.write(encode(OPCodes.HANDSHAKE, { 115 | v: 1, 116 | client_id: this.client.clientId, 117 | })); 118 | socket.pause(); 119 | socket.on('readable', () => { 120 | decode(socket, ({ op, data }) => { 121 | switch (op) { 122 | case OPCodes.PING: 123 | this.send(data, OPCodes.PONG); 124 | break; 125 | case OPCodes.FRAME: 126 | if (!data) { 127 | return; 128 | } 129 | if (data.cmd === 'AUTHORIZE' && data.evt !== 'ERROR') { 130 | findEndpoint() 131 | .then((endpoint) => { 132 | this.client.request.endpoint = endpoint; 133 | }) 134 | .catch((e) => { 135 | this.client.emit('error', e); 136 | }); 137 | } 138 | this.emit('message', data); 139 | break; 140 | case OPCodes.CLOSE: 141 | this.emit('close', data); 142 | break; 143 | default: 144 | break; 145 | } 146 | }); 147 | }); 148 | } 149 | 150 | onClose(e) { 151 | this.emit('close', e); 152 | } 153 | 154 | send(data, op = OPCodes.FRAME) { 155 | this.socket.write(encode(op, data)); 156 | } 157 | 158 | async close() { 159 | return new Promise((r) => { 160 | this.once('close', r); 161 | this.send({}, OPCodes.CLOSE); 162 | this.socket.end(); 163 | }); 164 | } 165 | 166 | ping() { 167 | this.send(uuid(), OPCodes.PING); 168 | } 169 | } 170 | 171 | module.exports = IPCTransport; 172 | module.exports.encode = encode; 173 | module.exports.decode = decode; 174 | -------------------------------------------------------------------------------- /src/transports/websocket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events'); 4 | const { browser } = require('../constants'); 5 | 6 | // eslint-disable-next-line 7 | const WebSocket = browser ? window.WebSocket : require('ws'); 8 | 9 | const pack = (d) => JSON.stringify(d); 10 | const unpack = (s) => JSON.parse(s); 11 | 12 | class WebSocketTransport extends EventEmitter { 13 | constructor(client) { 14 | super(); 15 | this.client = client; 16 | this.ws = null; 17 | this.tries = 0; 18 | } 19 | 20 | async connect() { 21 | const port = 6463 + (this.tries % 10); 22 | this.tries += 1; 23 | 24 | this.ws = new WebSocket( 25 | `ws://127.0.0.1:${port}/?v=1&client_id=${this.client.clientId}`, 26 | browser ? undefined : { origin: this.client.options.origin }, 27 | ); 28 | this.ws.onopen = this.onOpen.bind(this); 29 | this.ws.onclose = this.onClose.bind(this); 30 | this.ws.onerror = this.onError.bind(this); 31 | this.ws.onmessage = this.onMessage.bind(this); 32 | } 33 | 34 | onOpen() { 35 | this.emit('open'); 36 | } 37 | 38 | onClose(event) { 39 | if (!event.wasClean) { 40 | return; 41 | } 42 | this.emit('close', event); 43 | } 44 | 45 | onError(event) { 46 | try { 47 | this.ws.close(); 48 | } catch {} // eslint-disable-line no-empty 49 | 50 | if (this.tries > 20) { 51 | this.emit('error', event.error); 52 | } else { 53 | setTimeout(() => { 54 | this.connect(); 55 | }, 250); 56 | } 57 | } 58 | 59 | onMessage(event) { 60 | this.emit('message', unpack(event.data)); 61 | } 62 | 63 | send(data) { 64 | this.ws.send(pack(data)); 65 | } 66 | 67 | ping() {} // eslint-disable-line no-empty-function 68 | 69 | close() { 70 | return new Promise((r) => { 71 | this.once('close', r); 72 | this.ws.close(); 73 | }); 74 | } 75 | } 76 | 77 | module.exports = WebSocketTransport; 78 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let register; 4 | try { 5 | const { app } = require('electron'); 6 | register = app.setAsDefaultProtocolClient.bind(app); 7 | } catch (err) { 8 | try { 9 | register = require('register-scheme'); 10 | } catch (e) {} // eslint-disable-line no-empty 11 | } 12 | 13 | if (typeof register !== 'function') { 14 | register = () => false; 15 | } 16 | 17 | function pid() { 18 | if (typeof process !== 'undefined') { 19 | return process.pid; 20 | } 21 | return null; 22 | } 23 | 24 | const uuid4122 = () => { 25 | let uuid = ''; 26 | for (let i = 0; i < 32; i += 1) { 27 | if (i === 8 || i === 12 || i === 16 || i === 20) { 28 | uuid += '-'; 29 | } 30 | let n; 31 | if (i === 12) { 32 | n = 4; 33 | } else { 34 | const random = Math.random() * 16 | 0; 35 | if (i === 16) { 36 | n = (random & 3) | 0; 37 | } else { 38 | n = random; 39 | } 40 | } 41 | uuid += n.toString(16); 42 | } 43 | return uuid; 44 | }; 45 | 46 | module.exports = { 47 | pid, 48 | register, 49 | uuid: uuid4122, 50 | }; 51 | -------------------------------------------------------------------------------- /test/rp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-disable no-console */ 4 | 5 | try { 6 | require('wtfnode').init(); 7 | } catch (err) {} // eslint-disable-line no-empty 8 | 9 | const { Client } = require('../'); 10 | 11 | const client = new Client({ 12 | transport: 'ipc', 13 | }); 14 | 15 | client.on('VOICE_CHANNEL_SELECT', (args) => { 16 | client.subscribe('VOICE_STATE_UPDATE', { channel_id: args.channel_id }); 17 | }); 18 | 19 | client.on('VOICE_STATE_UPDATE', (args) => { 20 | console.log(args); 21 | }); 22 | 23 | client.on('ready', async () => { 24 | client.subscribe('VOICE_CHANNEL_SELECT'); 25 | }); 26 | 27 | client.login(require('./auth')).catch(console.error); 28 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: require.resolve('.'), 6 | output: { 7 | path: __dirname, 8 | filename: 'browser.js', 9 | library: 'RPC', 10 | libraryTarget: 'umd', 11 | }, 12 | }; 13 | --------------------------------------------------------------------------------