├── .github └── workflows │ ├── fmt.yml │ ├── lint.yml │ └── run.yml ├── .gitignore ├── LICENSE ├── README.md ├── deps.ts ├── examples ├── authorize.ts ├── messages.ts ├── rpc.ts └── voice_state.ts ├── mod.ts └── src ├── client.ts ├── conn.ts ├── types.ts └── util.ts /.github/workflows/fmt.yml: -------------------------------------------------------------------------------- 1 | name: Fmt 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Setup Repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Deno 18 | uses: denoland/setup-deno@main 19 | with: 20 | deno-version: v1.x 21 | 22 | - name: Fmt 23 | run: deno fmt --check 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Setup Repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Deno 18 | uses: denoland/setup-deno@main 19 | with: 20 | deno-version: v1.x 21 | 22 | - name: Lint 23 | run: deno lint 24 | -------------------------------------------------------------------------------- /.github/workflows/run.yml: -------------------------------------------------------------------------------- 1 | name: Run 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | deno: ['v1.x', 'canary'] 16 | 17 | steps: 18 | - name: Setup Repo 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup Deno 22 | uses: denoland/setup-deno@main 23 | with: 24 | deno-version: ${{ matrix.deno }} 25 | 26 | - name: Run 27 | run: deno run --unstable mod.ts 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 littledivy 4 | Copyright (c) 2022 Harmony Land 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord_rpc 2 | 3 | Discord RPC module for Deno. 4 | 5 | ## Usage 6 | 7 | You can import the module from https://deno.land/x/discord_rpc/mod.ts (don't 8 | forget to add a version!). 9 | 10 | You can also check the documentaton 11 | [here](https://doc.deno.land/https://deno.land/x/discord_rpc/mod.ts). 12 | 13 | Note that this module requires `--unstable` flag on Windows since Named Pipes 14 | support is added using FFI API, which is unstable. 15 | 16 | ## Example 17 | 18 | ```typescript 19 | import { Client } from "https://deno.land/x/discord_rpc/mod.ts"; 20 | 21 | const client = new Client({ 22 | id: "869104832227733514", 23 | }); 24 | 25 | await client.connect(); 26 | console.log(`Connected! User: ${client.userTag}`); 27 | 28 | await client.setActivity({ 29 | details: "Deno 🦕", 30 | state: "Testing...", 31 | }); 32 | ``` 33 | 34 | ## Contributing 35 | 36 | Contributions are welcome! 37 | 38 | - Please format code with `deno fmt` 39 | - Run `deno lint` before submitting PR 40 | 41 | ## License 42 | 43 | MIT licensed. Check [LICENSE](./LICENSE) for more info. 44 | 45 | Copyright 2021 © littledivy 46 | 47 | Copyright 2022-2023 © Harmony Land 48 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { connect } from "https://deno.land/x/namedpipe@0.1.2/mod.ts"; 2 | -------------------------------------------------------------------------------- /examples/authorize.ts: -------------------------------------------------------------------------------- 1 | // NOTE: For auth to work, you must have set at least one redirect URI in dev portal. 2 | 3 | import { Client } from "../mod.ts"; 4 | 5 | const client = new Client({ 6 | id: Deno.env.get("CLIENT_ID")!, 7 | secret: Deno.env.get("CLIENT_SECRET")!, 8 | scopes: ["rpc"], 9 | }); 10 | 11 | await client.connect(); 12 | console.log(`Connected! User: ${client.userTag}`); 13 | 14 | const { access_token: token } = await client.authorize(); 15 | console.log("Authorized! Token:", token); 16 | -------------------------------------------------------------------------------- /examples/messages.ts: -------------------------------------------------------------------------------- 1 | import { Client, Message } from "../mod.ts"; 2 | 3 | const client = new Client({ 4 | id: Deno.env.get("CLIENT_ID")!, 5 | secret: Deno.env.get("CLIENT_SECRET")!, 6 | scopes: ["rpc", "messages.read"], 7 | }); 8 | 9 | (async () => { 10 | for await (const event of client) { 11 | if (event.type === "dispatch") { 12 | if (event.event === "MESSAGE_CREATE") { 13 | const { message: msg, channel_id: channel } = event.data as { 14 | message: Message; 15 | channel_id: string; 16 | }; 17 | console.log( 18 | "MESSAGE_CREATE in", 19 | channel, 20 | "by", 21 | msg.author.username + ":", 22 | msg.content, 23 | ); 24 | } 25 | } 26 | } 27 | })(); 28 | 29 | await client.connect(); 30 | console.log(`Connected! User: ${client.userTag}`); 31 | 32 | await client.authorize(); 33 | 34 | await client.subscribe("MESSAGE_CREATE", { 35 | channel_id: Deno.env.get("CHANNEL"), 36 | }); 37 | -------------------------------------------------------------------------------- /examples/rpc.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "../mod.ts"; 2 | 3 | const client = new Client({ 4 | id: Deno.env.get("CLIENT_ID") ?? "869104832227733514", 5 | }); 6 | 7 | await client.connect(); 8 | console.log(`Connected! User: ${client.userTag}`); 9 | 10 | console.log( 11 | "Activity set:", 12 | await client.setActivity({ 13 | details: "Deno 🦕", 14 | state: "Testing...", 15 | }), 16 | ); 17 | -------------------------------------------------------------------------------- /examples/voice_state.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "../mod.ts"; 2 | 3 | const client = new Client({ 4 | id: Deno.env.get("CLIENT_ID")!, 5 | secret: Deno.env.get("CLIENT_SECRET")!, 6 | scopes: ["rpc", "rpc.voice.read"], 7 | }); 8 | 9 | (async () => { 10 | for await (const event of client) { 11 | if (event.type === "dispatch") { 12 | console.log(event.event, event.data); 13 | } 14 | } 15 | })(); 16 | 17 | await client.connect(); 18 | console.log(`Connected! User: ${client.userTag}`); 19 | 20 | await client.authorize(); 21 | 22 | await client.subscribe("VOICE_SETTINGS_UPDATE"); 23 | await client.subscribe("VOICE_SETTINGS_UPDATE_2"); 24 | await client.subscribe("VOICE_CHANNEL_SELECT"); 25 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/conn.ts"; 2 | export * from "./src/types.ts"; 3 | export * from "./src/util.ts"; 4 | export * from "./src/client.ts"; 5 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { DiscordIPC, PacketIPCEvent } from "./conn.ts"; 2 | import type { 3 | Activity, 4 | ActivityType, 5 | ApplicationPayload, 6 | AuthenticateResponsePayload, 7 | ChannelPayload, 8 | ClientConfig, 9 | Command, 10 | GetImageOptions, 11 | PartialChannel, 12 | PartialGuild, 13 | RelationshipPayload, 14 | RPCEvent, 15 | UserPayload, 16 | UserVoiceSettings, 17 | VoiceSettings, 18 | } from "./types.ts"; 19 | 20 | export interface ClientOptions { 21 | id: string; 22 | secret?: string; 23 | scopes?: string[]; 24 | } 25 | 26 | export interface ReadyEvent { 27 | type: "ready"; 28 | config: ClientConfig; 29 | user: UserPayload; 30 | } 31 | 32 | export interface AuthorizeEvent { 33 | type: "authorize"; 34 | code: string; 35 | } 36 | 37 | export interface AuthenticateEvent { 38 | type: "authenticate"; 39 | accessToken: string; 40 | expires: Date; 41 | scopes: string[]; 42 | user: UserPayload; 43 | application: ApplicationPayload; 44 | } 45 | 46 | export interface DispatchEvent { 47 | type: "dispatch"; 48 | event: keyof typeof RPCEvent; 49 | // deno-lint-ignore no-explicit-any 50 | data: any; 51 | } 52 | 53 | export type ClientEvent = 54 | | ReadyEvent 55 | | AuthorizeEvent 56 | | AuthenticateEvent 57 | | DispatchEvent 58 | | PacketIPCEvent; 59 | 60 | export class Client { 61 | ipc?: DiscordIPC; 62 | user?: UserPayload; 63 | config?: ClientConfig; 64 | token?: string; 65 | tokenExpires?: Date; 66 | application?: ApplicationPayload; 67 | 68 | #authenticated = false; 69 | #writers = new Set>(); 70 | #breakEventLoop?: boolean; 71 | #_eventLoop?: Promise; 72 | 73 | get userTag() { 74 | return this.user === undefined 75 | ? undefined 76 | : `${this.user.username}${ 77 | this.user.discriminator === "0" ? "" : `#${this.user.discriminator}` 78 | }`; 79 | } 80 | 81 | get authenticated() { 82 | return this.#authenticated; 83 | } 84 | 85 | constructor(public options: ClientOptions) {} 86 | 87 | async connect() { 88 | this.ipc = await DiscordIPC.connect(); 89 | this.#startEventLoop(); 90 | const payload = await this.ipc.login(this.options.id); 91 | this.user = payload.user; 92 | this.config = payload.config; 93 | return this; 94 | } 95 | 96 | /** 97 | * Set Presence Activity 98 | */ 99 | setActivity(activity?: Activity) { 100 | return this.ipc!.sendCommand< 101 | Activity & { application_id: string; type: ActivityType } 102 | >( 103 | "SET_ACTIVITY", 104 | { 105 | pid: Deno.pid, 106 | activity, 107 | }, 108 | ); 109 | } 110 | 111 | /** 112 | * Clears the currently set activity, if any. 113 | * This will hide the "Playing X" message displayed below the user's name. 114 | */ 115 | clearActivity() { 116 | return this.setActivity(); 117 | } 118 | 119 | /** 120 | * Starts complete OAuth2 flow, using given client secret 121 | * and scopes in ClientOptions. 122 | * 123 | * Throws if they are not provided. 124 | */ 125 | async authorize() { 126 | if (!this.options.secret) { 127 | throw new Error("Client secret is required"); 128 | } 129 | if (!this.options.scopes) { 130 | throw new Error("Scopes are required"); 131 | } 132 | if (!this.options.scopes.includes("rpc")) { 133 | throw new Error("Scopes must include `rpc`"); 134 | } 135 | 136 | const code = await this.ipc!.sendCommand<{ 137 | code: string; 138 | }>("AUTHORIZE", { 139 | client_id: this.options.id, 140 | client_secret: this.options.secret, 141 | scopes: this.options.scopes.join(" "), 142 | grant_type: "authorization_code", 143 | }).then((e) => e.code); 144 | 145 | const form = new URLSearchParams(); 146 | form.set("client_id", this.options.id); 147 | form.set("client_secret", this.options.secret); 148 | form.set("scopes", this.options.scopes.join(" ")); 149 | form.set("grant_type", "authorization_code"); 150 | form.set("code", code); 151 | 152 | const res = await fetch("https://discord.com/api/v10/oauth2/token", { 153 | method: "POST", 154 | headers: { 155 | "Content-Type": "application/x-www-form-urlencoded", 156 | }, 157 | body: form.toString(), 158 | }).then((r) => r.json()); 159 | 160 | const token = res.access_token; 161 | if (typeof token !== "string") { 162 | throw new Error("Failed to get access token!"); 163 | } 164 | 165 | this.token = token; 166 | return this.authenticate(this.token); 167 | } 168 | 169 | /** 170 | * Authenticates with given access token. 171 | * This is automatically called when using `authorize()`. 172 | * 173 | * @param token OAuth2 Access Token 174 | * @returns Authentication Response 175 | */ 176 | authenticate(token: string) { 177 | return this.ipc!.sendCommand< 178 | AuthenticateResponsePayload 179 | >("AUTHENTICATE", { 180 | access_token: token, 181 | }); 182 | } 183 | 184 | async subscribe>( 185 | event: keyof typeof RPCEvent, 186 | args?: T, 187 | ) { 188 | await this.ipc!.sendCommand("SUBSCRIBE", args ?? {}, event); 189 | } 190 | 191 | async unsubscribe>( 192 | event: keyof typeof RPCEvent, 193 | args?: T, 194 | ) { 195 | await this.ipc!.sendCommand("UNSUBSCRIBE", args ?? {}, event); 196 | } 197 | 198 | getChannels(guildID?: string) { 199 | return this.ipc!.sendCommand<{ channels: PartialChannel[] }>( 200 | "GET_CHANNELS", 201 | { 202 | guild_id: guildID, 203 | }, 204 | ).then((e) => e.channels); 205 | } 206 | 207 | getChannel(channelID?: string) { 208 | return this.ipc!.sendCommand( 209 | "GET_CHANNEL", 210 | { 211 | channel_id: channelID, 212 | }, 213 | ); 214 | } 215 | 216 | getGuilds() { 217 | return this.ipc!.sendCommand<{ guilds: PartialGuild[] }>( 218 | "GET_GUILDS", 219 | {}, 220 | ).then((e) => e.guilds); 221 | } 222 | 223 | getGuild(guildID?: string) { 224 | return this.ipc!.sendCommand( 225 | "GET_GUILD", 226 | { 227 | guild_id: guildID, 228 | }, 229 | ); 230 | } 231 | 232 | getVoiceSettings() { 233 | return this.ipc!.sendCommand("GET_VOICE_SETTINGS", {}); 234 | } 235 | 236 | setVoiceSettings(settings: Partial) { 237 | return this.ipc!.sendCommand( 238 | "SET_VOICE_SETTINGS", 239 | settings, 240 | ); 241 | } 242 | 243 | setUserVoiceSettings(settings: Partial) { 244 | return this.ipc!.sendCommand( 245 | "SET_USER_VOICE_SETTINGS", 246 | settings, 247 | ); 248 | } 249 | 250 | getSelectedVoiceChannel() { 251 | return this.ipc!.sendCommand( 252 | "GET_SELECTED_VOICE_CHANNEL", 253 | {}, 254 | ); 255 | } 256 | 257 | selectVoiceChannel(channelID: string, force = false, timeout = 1) { 258 | return this.ipc!.sendCommand("SELECT_VOICE_CHANNEL", { 259 | channel_id: channelID, 260 | force, 261 | timeout, 262 | }); 263 | } 264 | 265 | selectTextChannel(channelID: string, timeout = 1) { 266 | return this.ipc!.sendCommand("SELECT_TEXT_CHANNEL", { 267 | channel_id: channelID, 268 | timeout, 269 | }); 270 | } 271 | 272 | async sendActivityJoinInvite(userID: string) { 273 | await this.ipc!.sendCommand("SEND_ACTIVITY_JOIN_INVITE", { 274 | user_id: userID, 275 | }); 276 | } 277 | 278 | // TODO: Make sure it works 279 | async closeActivityJoinRequest(userID: string) { 280 | await this.ipc!.sendCommand("CLOSE_ACTIVITY_JOIN_REQUEST", { 281 | user_id: userID, 282 | }); 283 | } 284 | 285 | async closeActivityRequest(userID: string) { 286 | await this.ipc!.sendCommand("CLOSE_ACTIVITY_REQUEST", { 287 | user_id: userID, 288 | }); 289 | } 290 | 291 | getRelationships() { 292 | return this.ipc!.sendCommand<{ relationships: RelationshipPayload[] }>( 293 | "GET_RELATIONSHIPS", 294 | {}, 295 | ).then((e) => e.relationships); 296 | } 297 | 298 | async getImage(options: GetImageOptions) { 299 | const base64 = await this.ipc!.sendCommand( 300 | "GET_IMAGE", 301 | options as unknown as Record, 302 | ); 303 | // TODO: Decode to Uint8Array? 304 | return base64; 305 | } 306 | 307 | #emit(event: ClientEvent) { 308 | for (const writer of this.#writers) { 309 | writer.enqueue(event); 310 | } 311 | } 312 | 313 | #closeWriters() { 314 | this.#breakEventLoop = true; 315 | for (const writer of this.#writers) { 316 | writer.close(); 317 | } 318 | } 319 | 320 | close() { 321 | this.#closeWriters(); 322 | this.ipc!.close(); 323 | } 324 | 325 | [Symbol.asyncIterator](): AsyncIterableIterator { 326 | let ctx: ReadableStreamDefaultController; 327 | return new ReadableStream({ 328 | start: (controller) => { 329 | ctx = controller; 330 | this.#writers.add(ctx); 331 | }, 332 | cancel: () => { 333 | this.#writers.delete(ctx); 334 | }, 335 | })[Symbol.asyncIterator](); 336 | } 337 | 338 | #startEventLoop() { 339 | this.#_eventLoop = (async () => { 340 | for await (const event of this.ipc!) { 341 | if (this.#breakEventLoop) { 342 | break; 343 | } 344 | 345 | if (event.type === "close") { 346 | this.#closeWriters(); 347 | break; 348 | } else if (event.type === "packet") { 349 | this.#emit({ 350 | type: "packet", 351 | op: event.op, 352 | data: event.data, 353 | }); 354 | 355 | const { cmd, data, evt } = event.data as { 356 | cmd: keyof typeof Command; 357 | // deno-lint-ignore no-explicit-any 358 | data: any; 359 | evt: keyof typeof RPCEvent | null; 360 | }; 361 | 362 | if (cmd === "DISPATCH" && evt !== null) { 363 | this.#emit({ 364 | type: "dispatch", 365 | event: evt, 366 | data, 367 | }); 368 | 369 | if (evt === "READY") { 370 | this.user = data.user; 371 | this.config = data.config; 372 | 373 | this.#emit({ 374 | type: "ready", 375 | config: this.config!, 376 | user: this.user!, 377 | }); 378 | } 379 | } else if (cmd === "AUTHORIZE") { 380 | this.#emit({ 381 | type: "authorize", 382 | code: data.code, 383 | }); 384 | } else if (cmd === "AUTHENTICATE") { 385 | this.#authenticated = true; 386 | this.user = data.user; 387 | this.application = data.application; 388 | this.token = data.access_token; 389 | this.tokenExpires = new Date(data.expires); 390 | 391 | this.#emit({ 392 | type: "authenticate", 393 | user: this.user!, 394 | application: this.application!, 395 | accessToken: this.token!, 396 | expires: this.tokenExpires!, 397 | scopes: data.scopes, 398 | }); 399 | } 400 | } 401 | } 402 | })(); 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /src/conn.ts: -------------------------------------------------------------------------------- 1 | import { Command, OpCode, ReadyEventPayload, RPCEvent } from "./types.ts"; 2 | import { encode, findIPC } from "./util.ts"; 3 | 4 | export interface PacketIPCEvent> { 5 | type: "packet"; 6 | op: OpCode; 7 | data: T; 8 | } 9 | 10 | export interface CloseIPCEvent { 11 | type: "close"; 12 | } 13 | 14 | export type IPCEvent = PacketIPCEvent | CloseIPCEvent; 15 | 16 | interface PromiseController { 17 | resolve: CallableFunction; 18 | reject: CallableFunction; 19 | } 20 | 21 | export class DiscordIPC { 22 | #ipcHandle: Deno.Conn; 23 | #writers = new Set>(); 24 | #_eventLoop!: Promise; 25 | #breakEventLoop?: boolean; 26 | #header = new Uint8Array(8); 27 | #headerView = new DataView(this.#header.buffer); 28 | #commandQueue = new Map< 29 | string, 30 | PromiseController 31 | >(); 32 | #readyHandle?: PromiseController; 33 | 34 | constructor(conn: Deno.Conn) { 35 | this.#ipcHandle = conn; 36 | this.#startEventLoop(); 37 | } 38 | 39 | static async connect() { 40 | const conn = await findIPC(); 41 | return new DiscordIPC(conn); 42 | } 43 | 44 | #startEventLoop() { 45 | this.#_eventLoop = (async () => { 46 | try { 47 | while (true) { 48 | if (this.#breakEventLoop === true) break; 49 | await this.#read(); 50 | } 51 | } catch (_) { 52 | this.#closeWriters(); 53 | } 54 | })(); 55 | } 56 | 57 | /** 58 | * Send a packet to Discord IPC. Returns nonce. 59 | * 60 | * Nonce is generated if the payload does not have a `nonce` property 61 | * and is added to payload object too. 62 | * 63 | * If payload object does contain a nonce, then it is returned instead. 64 | */ 65 | async send>(op: OpCode, payload: T) { 66 | if (typeof payload !== "object" || payload === null) { 67 | throw new TypeError("Payload must be an object"); 68 | } 69 | 70 | let nonce: string; 71 | if (typeof payload.nonce === "undefined") { 72 | nonce = crypto.randomUUID(); 73 | Object.defineProperty(payload, "nonce", { 74 | value: nonce, 75 | }); 76 | } else { 77 | nonce = payload.nonce as string; 78 | } 79 | 80 | const data = encode(op, JSON.stringify(payload)); 81 | await this.#ipcHandle.write(data); 82 | return nonce; 83 | } 84 | 85 | /** 86 | * Sends a Managed Command to Discord IPC. 87 | * 88 | * Managed means it resolves when Discord sends back some response, 89 | * or rejects when an ERROR event is DISPATCHed instead. 90 | * 91 | * @param cmd Command name 92 | * @param args Arguments object 93 | * @returns Command response 94 | */ 95 | sendCommand< 96 | T = unknown, 97 | T2 extends Record = Record, 98 | >( 99 | cmd: Command | keyof typeof Command, 100 | args: T2, 101 | evt?: keyof typeof RPCEvent, 102 | ): Promise { 103 | return new Promise((resolve, reject) => { 104 | const nonce = crypto.randomUUID(); 105 | this.#commandQueue.set(nonce, { resolve, reject }); 106 | this.send(OpCode.FRAME, { 107 | cmd: typeof cmd === "number" ? Command[cmd] : cmd, 108 | args, 109 | nonce, 110 | evt, 111 | }).catch(reject); 112 | }); 113 | } 114 | 115 | /** 116 | * Performs initial handshake. 117 | * 118 | * @param clientID Application ID from Developer Portal 119 | */ 120 | login(clientID: string) { 121 | return new Promise((resolve, reject) => { 122 | this.#readyHandle = { resolve, reject }; 123 | this.send(OpCode.HANDSHAKE, { v: "1", client_id: clientID }).catch( 124 | reject, 125 | ); 126 | }); 127 | } 128 | 129 | #closeWriters() { 130 | for (const ctx of this.#writers) { 131 | ctx.close(); 132 | this.#writers.delete(ctx); 133 | } 134 | this.#breakEventLoop = true; 135 | } 136 | 137 | /** 138 | * Closes the connection to Discord IPC Socket 139 | * and any open ReadableStreams for events. 140 | */ 141 | close() { 142 | this.#closeWriters(); 143 | this.#ipcHandle.close(); 144 | this.#emit({ type: "close" }); 145 | } 146 | 147 | #emit(event: IPCEvent) { 148 | for (const ctx of this.#writers) { 149 | ctx.enqueue(event); 150 | } 151 | } 152 | 153 | async #read() { 154 | let headerRead = 0; 155 | while (headerRead < 8) { 156 | const read = await this.#ipcHandle.read( 157 | this.#header.subarray(headerRead), 158 | ); 159 | if (read === null) throw new Error("Connection closed"); 160 | headerRead += read; 161 | } 162 | 163 | const op = this.#headerView.getInt32(0, true) as OpCode; 164 | const payloadLength = this.#headerView.getInt32(4, true); 165 | 166 | const data = new Uint8Array(payloadLength); 167 | let bodyRead = 0; 168 | while (bodyRead < payloadLength) { 169 | const read = await this.#ipcHandle.read(data.subarray(bodyRead)); 170 | if (read === null) throw new Error("Connection closed"); 171 | bodyRead += read; 172 | } 173 | 174 | const payload = JSON.parse(new TextDecoder().decode(data)); 175 | 176 | const handle = this.#commandQueue.get(payload.nonce); 177 | if (handle) { 178 | if (payload.evt === "ERROR") { 179 | handle.reject( 180 | new Error(`(${payload.data.code}) ${payload.data.message}`), 181 | ); 182 | } else { 183 | handle.resolve(payload.data); 184 | } 185 | this.#commandQueue.delete(payload.nonce); 186 | } else if (payload.cmd === "DISPATCH" && payload.evt === "READY") { 187 | this.#readyHandle?.resolve(payload.data); 188 | this.#readyHandle = undefined; 189 | } else if (op === OpCode.CLOSE && payload.code === 4000) { 190 | this.#readyHandle?.reject( 191 | new Error(`Connection closed (${payload.code}): ${payload.message}`), 192 | ); 193 | this.#readyHandle = undefined; 194 | } 195 | 196 | this.#emit({ type: "packet", op, data: payload }); 197 | } 198 | 199 | [Symbol.asyncIterator](): AsyncIterableIterator { 200 | let ctx: ReadableStreamDefaultController; 201 | return new ReadableStream({ 202 | start: (controller) => { 203 | ctx = controller; 204 | this.#writers.add(ctx); 205 | }, 206 | cancel: () => { 207 | this.#writers.delete(ctx); 208 | }, 209 | })[Symbol.asyncIterator](); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file camelcase 2 | 3 | export interface Activity { 4 | type?: ActivityType; 5 | details?: string; 6 | state?: string; 7 | assets?: { 8 | large_image?: string; 9 | large_text?: string; 10 | small_image?: string; 11 | small_text?: string; 12 | }; 13 | party?: { 14 | id?: string; 15 | size?: number; 16 | }; 17 | timestamps?: { 18 | start?: number; 19 | end?: number; 20 | }; 21 | secrets?: { 22 | match?: string; 23 | join?: string; 24 | spectate?: string; 25 | }; 26 | buttons?: { 27 | label?: string; 28 | url?: string; 29 | }[]; 30 | } 31 | 32 | export enum ActivityType { 33 | PLAYING, 34 | STREAMING, 35 | LISTENING, 36 | WATCHING, 37 | CUSTOM, 38 | COMPETING, 39 | } 40 | 41 | export enum OpCode { 42 | HANDSHAKE, 43 | FRAME, 44 | CLOSE, 45 | PING, 46 | PONG, 47 | } 48 | 49 | /** Commands used to communicate with IPC. */ 50 | export enum Command { 51 | DISPATCH, 52 | AUTHORIZE, 53 | AUTHENTICATE, 54 | GET_GUILD, 55 | GET_GUILDS, 56 | GET_CHANNEL, 57 | GET_CHANNELS, 58 | SUBSCRIBE, 59 | UNSUBSCRIBE, 60 | SET_USER_VOICE_SETTINGS, 61 | SELECT_VOICE_CHANNEL, 62 | GET_SELECTED_VOICE_CHANNEL, 63 | SELECT_TEXT_CHANNEL, 64 | GET_VOICE_SETTINGS, 65 | SET_VOICE_SETTINGS, 66 | CAPTURE_SHORTCUT, 67 | SET_CERTIFIED_DEVICES, 68 | SET_ACTIVITY, 69 | SEND_ACTIVITY_JOIN_INVITE, 70 | CLOSE_ACTIVITY_REQUEST, 71 | SET_USER_ACHIEVEMENT, 72 | GET_USER_ACHIEVEMENTS, 73 | GET_ACTIVITY_JOIN_TICKET, 74 | SEND_GENERIC_EVENT, 75 | NETWORKING_SYSTEM_METRICS, 76 | NETWORKING_PEER_METRICS, 77 | NETWORKING_CREATE_TOKEN, 78 | GET_SKUS, 79 | GET_ENTITLEMENTS, 80 | GET_NETWORKING_CONFIG, 81 | START_PURCHASE, 82 | GET_ENTITLEMENT_TICKET, 83 | GET_APPLICATION_TICKET, 84 | VALIDATE_APPLICATION, 85 | OPEN_OVERLAY_VOICE_SETTINGS, 86 | OPEN_OVERLAY_GUILD_INVITE, 87 | OVEN_OVERLAY_ACTIVITY_INVITE, 88 | SET_OVERLAY_LOCKED, 89 | DISCONNECT_FROM_LOBBY_VOICE, 90 | CONNECT_TO_LOBBY_VOICE, 91 | SEARCH_LOBBIES, 92 | SEND_TO_LOBBY, 93 | DISCONNECT_FROM_LOBBY, 94 | CONNECT_TO_LOBBY, 95 | UPDATE_LOBBY_MEMBER, 96 | DELETE_LOBBY, 97 | UPDATE_LOBBY, 98 | CREATE_LOBBY, 99 | GET_IMAGE, 100 | BROWSER_HANDOFF, 101 | OVERLAY, 102 | GUILD_TEMPLATE_BROWSER, 103 | GIFT_CODE_BROWSER, 104 | BRAINTREE_POPUP_BRIDGE_CALLBACK, 105 | CONNECTIONS_CALLBACK, 106 | DEEP_LINK, 107 | INVITE_BROWSER, 108 | OPEN_INVITE_DIALOG, 109 | ACCEPT_ACTIVITY_INVITE, 110 | ACTIVITY_INVITE_USER, 111 | CLOSE_ACTIVITY_JOIN_REQUEST, 112 | SET_VOICE_SETTINGS_2, 113 | SET_USER_VOICE_SETTINGS_2, 114 | CREATE_CHANNEL_INVITE, 115 | GET_RELATIONSHIPS, 116 | } 117 | 118 | /** Events `DISPATCH`'d from IPC. */ 119 | export enum RPCEvent { 120 | READY, 121 | ERROR, 122 | GUILD_STATUS, 123 | GUILD_CREATE, 124 | CHANNEL_CREATE, 125 | VOICE_CHANNEL_SELECT, 126 | VOICE_STATE_CREATE, 127 | VOICE_STATE_UPDATE, 128 | VOICE_STATE_DELETE, 129 | VOICE_SETTINGS_UPDATE, 130 | VOICE_CONNECTION_STATUS, 131 | SPEAKING_START, 132 | SPEAKING_STOP, 133 | MESSAGE_CREATE, 134 | MESSAGE_UPDATE, 135 | MESSAGE_DELETE, 136 | NOTIFICATION_CREATE, 137 | CAPTURE_SHORTCUT_CHANGE, 138 | ACTIVITY_JOIN, 139 | ACTIVITY_JOIN_REQUEST, 140 | ACTIVITY_SPECTATE, 141 | CURRENT_USER_UPDATE, 142 | RELATIONSHIP_UPDATE, 143 | VOICE_SETTINGS_UPDATE_2, 144 | GAME_JOIN, 145 | GAME_SPECTATE, 146 | LOBBY_DELETE, 147 | LOBBY_UPDATE, 148 | LOBBY_MEMBER_CONNECT, 149 | LOBBY_MEMBER_DISCONNECT, 150 | LOBBY_MEMBER_UPDATE, 151 | LOBBY_MESSAGE, 152 | OVERLAY, 153 | OVERLAY_UPDATE, 154 | ENTITLEMENT_CREATE, 155 | ENTITLEMENT_DELETE, 156 | USER_ACHIEVEMENT_UPDATE, 157 | } 158 | 159 | /** Nitro type of UserPayload. */ 160 | export enum PremiumType { 161 | NONE, 162 | NITRO_CLASSIC, 163 | NITRO, 164 | } 165 | 166 | /** Partial UserPayload object */ 167 | export interface UserPayload { 168 | id: string; 169 | username: string; 170 | discriminator: string; 171 | avatar: string; 172 | bot?: boolean; 173 | publicFlags?: number; 174 | premium_type?: PremiumType; 175 | } 176 | 177 | /** Client Config sent on Ready event. */ 178 | export interface ClientConfig { 179 | cdn_host: string; 180 | api_endpoint: string; 181 | environment: string; 182 | } 183 | 184 | export interface UserPayloadVoiceSettingsPan { 185 | left: number; 186 | right: number; 187 | } 188 | 189 | export interface UserPayloadVoiceSettings { 190 | user_id: string; 191 | pan?: UserPayloadVoiceSettingsPan; 192 | volume?: number; 193 | mute?: boolean; 194 | } 195 | 196 | export interface VoiceSettingsInput { 197 | device_id: string; 198 | volume: number; 199 | // deno-lint-ignore no-explicit-any 200 | available_devices: any[]; 201 | } 202 | 203 | export type VoiceSettingsOutput = VoiceSettingsInput; 204 | 205 | export interface ShortcutKeyCombo { 206 | type: KeyType; 207 | code: number; 208 | name: string; 209 | } 210 | 211 | export interface VoiceSettingsMode { 212 | type: string; 213 | auto_threshold: boolean; 214 | threshold: number; 215 | shortcut: ShortcutKeyCombo; 216 | delay: number; 217 | } 218 | 219 | export interface UserVoiceSettingsPan { 220 | left: number; 221 | right: number; 222 | } 223 | 224 | export interface UserVoiceSettings { 225 | user_id: string; 226 | pan?: UserVoiceSettingsPan; 227 | volume?: number; 228 | mute?: boolean; 229 | } 230 | 231 | export enum KeyType { 232 | KEYBOARD_KEY, 233 | MOUSE_BUTTON, 234 | KEYBOARD_MODIFIER_KEY, 235 | GAMEPAD_BUTTON, 236 | } 237 | 238 | export interface VoiceSettings { 239 | input: VoiceSettingsInput; 240 | output: VoiceSettingsOutput; 241 | mode: VoiceSettingsMode; 242 | automatic_gain_control: boolean; 243 | echo_cancellation: boolean; 244 | noise_cancellation: boolean; 245 | qos: boolean; 246 | silence_warning: boolean; 247 | deaf: boolean; 248 | mute: boolean; 249 | } 250 | 251 | export interface DeviceVendor { 252 | name: string; 253 | url: string; 254 | } 255 | 256 | export interface DeviceModel { 257 | name: string; 258 | url: string; 259 | } 260 | 261 | export enum DeviceType { 262 | AudioInput = "audioinput", 263 | AudioOutput = "audiooutput", 264 | VideoInput = "videoinput", 265 | } 266 | 267 | export interface Device { 268 | type: DeviceType; 269 | id: string; 270 | vendor: DeviceVendor; 271 | model: DeviceModel; 272 | related: string[]; 273 | echo_cancellation?: boolean; 274 | noise_cancellation?: boolean; 275 | automatic_gain_control?: boolean; 276 | hardware_mute?: boolean; 277 | } 278 | 279 | export interface VoiceStateData { 280 | mute: boolean; 281 | deaf: boolean; 282 | self_mute: boolean; 283 | self_deaf: boolean; 284 | suppress: boolean; 285 | } 286 | 287 | export interface VoiceState { 288 | voice_state: VoiceStateData; 289 | user: UserPayload; 290 | nick?: string | null; 291 | mute: boolean; 292 | volume: number; 293 | pan: UserPayloadVoiceSettingsPan; 294 | } 295 | 296 | export interface Attachment { 297 | content_type?: string; 298 | ephemeral: boolean; 299 | filename: string; 300 | width?: number; 301 | height?: number; 302 | id: string; 303 | proxy_url: string; 304 | size: number; 305 | url: string; 306 | } 307 | 308 | export interface EmbedPayload { 309 | title?: string; 310 | type?: EmbedTypes; 311 | description?: string; 312 | url?: string; 313 | timestamp?: string; 314 | color?: number; 315 | footer?: EmbedFooter; 316 | image?: EmbedImage; 317 | thumbnail?: EmbedThumbnail; 318 | video?: EmbedVideo; 319 | provider?: EmbedProvider; 320 | author?: EmbedAuthor; 321 | fields?: EmbedField[]; 322 | } 323 | 324 | export type EmbedTypes = 325 | | "rich" 326 | | "image" 327 | | "video" 328 | | "gifv" 329 | | "article" 330 | | "link"; 331 | 332 | export interface EmbedField { 333 | name: string; 334 | value: string; 335 | inline?: boolean; 336 | } 337 | 338 | export interface EmbedAuthor { 339 | name?: string; 340 | url?: string; 341 | icon_url?: string; 342 | proxy_icon_url?: string; 343 | } 344 | 345 | export interface EmbedFooter { 346 | text: string; 347 | icon_url?: string; 348 | proxy_icon_url?: string; 349 | } 350 | 351 | export interface EmbedImage { 352 | url?: string; 353 | proxy_url?: string; 354 | height?: number; 355 | width?: number; 356 | } 357 | 358 | export interface EmbedProvider { 359 | name?: string; 360 | url?: string; 361 | } 362 | 363 | export interface EmbedVideo { 364 | url?: string; 365 | height?: number; 366 | width?: number; 367 | } 368 | 369 | export interface EmbedThumbnail { 370 | url?: string; 371 | proxy_url?: string; 372 | height?: number; 373 | width?: number; 374 | } 375 | 376 | export enum MessageType { 377 | DEFAULT, 378 | RECIPIENT_ADD, 379 | RECIPIENT_REMOVE, 380 | CALL, 381 | CHANNEL_NAME_CHANGE, 382 | CHANNEL_ICON_CHANGE, 383 | CHANNEL_PINNED_MESSAGE, 384 | GUILD_MEMBER_JOIN, 385 | USER_PREMIUM_GUILD_SUBSCRIPTION, 386 | USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1, 387 | USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2, 388 | USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3, 389 | CHANNEL_FOLLOW_ADD, 390 | GUILD_DISCOVERY_DISQUALIFIED = 14, 391 | GUILD_DISCOVERY_REQUALIFIED = 15, 392 | GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING = 16, 393 | GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING = 17, 394 | THREAD_CREATED = 18, 395 | REPLY = 19, 396 | APPLICATION_COMMAND = 20, 397 | THREAD_STARTER_MESSAGE = 21, 398 | GUILD_INVITE_REMINDER = 22, 399 | } 400 | 401 | export interface Message { 402 | id: string; 403 | content: string; 404 | nick: string; 405 | timestamp: string; 406 | tts: boolean; 407 | mentions: UserPayload[]; 408 | mention_roles: string[]; 409 | embeds: EmbedPayload[]; 410 | attachments: Attachment[]; 411 | author: UserPayload; 412 | pinned: boolean; 413 | type: MessageType; 414 | author_color: string; 415 | } 416 | 417 | export interface ApplicationPayload { 418 | id: string; 419 | name: string; 420 | icon: string | null; 421 | description?: string; 422 | summary?: string; 423 | hook: boolean; 424 | bot_public?: boolean; 425 | bot_require_code_grant?: boolean; 426 | verify_key: string; 427 | } 428 | 429 | export interface AuthenticateResponsePayload { 430 | application: ApplicationPayload; 431 | scopes: string[]; 432 | expires: string; 433 | user: UserPayload; 434 | access_token: string; 435 | } 436 | 437 | export enum ChannelType { 438 | GUILD_TEXT = 0, 439 | DM = 1, 440 | GUILD_VOICE = 2, 441 | GROUP_DM = 3, 442 | GUILD_CATEGORY = 4, 443 | GUILD_NEWS = 5, 444 | GUILD_STORE = 6, 445 | NEWS_THREAD = 10, 446 | PUBLIC_THREAD = 11, 447 | PRIVATE_THREAD = 12, 448 | GUILD_STAGE_VOICE = 13, 449 | } 450 | 451 | export interface ChannelPayload { 452 | id: string; 453 | name: string; 454 | type: ChannelType; 455 | topic?: string; 456 | bitrate?: number; 457 | user_limit?: number; 458 | guild_id: string; 459 | position: number; 460 | voice_states?: VoiceState[]; 461 | messages?: Message[]; 462 | } 463 | 464 | export interface PartialChannel { 465 | id: string; 466 | name: string; 467 | type: ChannelType; 468 | } 469 | 470 | export interface Guild { 471 | id: string; 472 | name: string; 473 | icon_url: string | null; 474 | } 475 | 476 | export interface PartialGuild { 477 | id: string; 478 | name: string; 479 | } 480 | 481 | export interface GetImageOptions { 482 | type: "user"; 483 | id: string; 484 | format: "png" | "apng" | "webp" | "gif" | "jpg"; 485 | size: number; 486 | } 487 | 488 | export enum LobbyType { 489 | Private = 1, 490 | Public = 2, 491 | } 492 | 493 | export interface LobbyMetadata { 494 | [name: string]: string | number; 495 | } 496 | 497 | export interface LobbyOptions { 498 | type?: LobbyType; 499 | owner_id?: string; 500 | capacity?: number; 501 | metadata?: LobbyMetadata; 502 | locked?: boolean; 503 | } 504 | 505 | export interface Lobby { 506 | application_id: string; 507 | capacity: number; 508 | id: string; 509 | locked: boolean; 510 | members: Array<{ metadata: LobbyMetadata; user: UserPayload }>; 511 | metadata: LobbyMetadata; 512 | owner_id: string; 513 | region: string; 514 | secret: string; 515 | type: LobbyType; 516 | voice_states: VoiceState[]; 517 | } 518 | 519 | export interface NetworkingConfig { 520 | address: string; 521 | /** Not the token you think of, lol. */ 522 | token: string; 523 | } 524 | 525 | export interface PresencePayload { 526 | status: "online" | "offline" | "dnd" | "invisible" | "idle"; 527 | activities?: Activity[]; 528 | } 529 | 530 | export interface RelationshipPayload { 531 | type: number; 532 | user: UserPayload; 533 | presence: PresencePayload; 534 | } 535 | 536 | export interface ReadyEventPayload { 537 | v: 1; 538 | user: UserPayload; 539 | config: ClientConfig; 540 | } 541 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { connect } from "../deps.ts"; 2 | 3 | export function encode(op: number, payloadString: string) { 4 | const payload = new TextEncoder().encode(payloadString); 5 | const data = new Uint8Array(4 + 4 + payload.byteLength); 6 | const view = new DataView(data.buffer); 7 | view.setInt32(0, op, true); 8 | view.setInt32(4, payload.byteLength, true); 9 | data.set(payload, 8); 10 | return data; 11 | } 12 | 13 | export function getIPCPath(id: number) { 14 | if (id < 0 || id > 9) { 15 | throw new RangeError( 16 | `Tried all possible IPC paths 0-9, make sure Discord is open. It must be installed locally, not just open on a web browser.`, 17 | ); 18 | } 19 | 20 | const suffix = `discord-ipc-${id}`; 21 | let prefix; 22 | 23 | if (Deno.build.os === "windows") { 24 | prefix = `\\\\.\\pipe\\`; 25 | } else { 26 | prefix = (Deno.env.get("XDG_RUNTIME_DIR") ?? Deno.env.get("TMPDIR") ?? 27 | Deno.env.get("TMP") ?? Deno.env.get("TEMP") ?? "/tmp") + "/"; 28 | } 29 | 30 | return `${prefix}${suffix}`; 31 | } 32 | 33 | export async function findIPC(id = 0): Promise { 34 | const path = getIPCPath(id); 35 | 36 | try { 37 | await Deno.stat(path); 38 | } catch { 39 | return await findIPC(id + 1); 40 | } 41 | 42 | if (Deno.build.os === "windows") { 43 | return await connect(path) as unknown as Deno.Conn; 44 | } else { 45 | return await Deno.connect({ 46 | path, 47 | transport: "unix", 48 | }); 49 | } 50 | } 51 | --------------------------------------------------------------------------------