From b93a37c76de9c323ce9e5b34721b4845e8c3fa31 Mon Sep 17 00:00:00 2001 From: noeppi_noeppi Date: Mon, 11 Oct 2021 21:35:01 +0200 Subject: [PATCH] Add a discord rpc service --- .../extension/discordRpcAuth.ts | 95 +++++++++++++++++++ nodecg-io-discord-rpc/extension/index.ts | 36 +++++++ nodecg-io-discord-rpc/package.json | 50 ++++++++++ nodecg-io-discord-rpc/schema.json | 32 +++++++ nodecg-io-discord-rpc/tsconfig.json | 3 + package-lock.json | 69 ++++++++++++++ samples/discord-rpc/extension/index.ts | 17 ++++ samples/discord-rpc/package.json | 24 +++++ samples/discord-rpc/tsconfig.json | 3 + 9 files changed, 329 insertions(+) create mode 100644 nodecg-io-discord-rpc/extension/discordRpcAuth.ts create mode 100644 nodecg-io-discord-rpc/extension/index.ts create mode 100644 nodecg-io-discord-rpc/package.json create mode 100644 nodecg-io-discord-rpc/schema.json create mode 100644 nodecg-io-discord-rpc/tsconfig.json create mode 100644 samples/discord-rpc/extension/index.ts create mode 100644 samples/discord-rpc/package.json create mode 100644 samples/discord-rpc/tsconfig.json diff --git a/nodecg-io-discord-rpc/extension/discordRpcAuth.ts b/nodecg-io-discord-rpc/extension/discordRpcAuth.ts new file mode 100644 index 000000000..9f7bfe192 --- /dev/null +++ b/nodecg-io-discord-rpc/extension/discordRpcAuth.ts @@ -0,0 +1,95 @@ +// Required because discord-rpc does not expose the access token +// and does not even use the refresh token. +// Without this, users would be prompted on whether they agree, whenever +// the service connects to discord. + +import * as rpc from "discord-rpc"; +import fetch from "node-fetch"; +import { URLSearchParams } from "url"; + +// Defined here because nodejs does not seem to accept +// circular imports. +export interface DiscordRpcConfig { + clientId: string; + clientSecret: string; + accessToken?: string; + refreshToken?: string; + redirectUrl?: string; + expireTime?: number; +} + +// Fill the config values and create matching login data +export async function createLoginData( + client: rpc.Client, + config: DiscordRpcConfig, + scopes: string[], +): Promise { + await client.connect(config.clientId); + const redirectUrl = config.redirectUrl === undefined ? "http://127.0.0.1" : config.redirectUrl; + if (config.accessToken === undefined || config.refreshToken === undefined || config.expireTime === undefined) { + // Call authorize + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const authorizeResult = await client.request("AUTHORIZE", { + client_id: config.clientId, + scopes: scopes, + }); + const response = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: encodeQuery({ + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: "authorization_code", + code: authorizeResult.code, + redirect_uri: redirectUrl, + scope: scopes.join(" "), + }), + }); + const data = await response.json(); + if ("error" in data) + throw new Error("error_description" in data ? data.error_description : "Unknown discord rpc login error"); + config.accessToken = data.access_token; + config.refreshToken = data.refresh_token; + config.expireTime = data.expires_in + Date.now() / 1000 - 1000; + } else if (config.expireTime < Date.now() / 1000) { + // Token expired. Request a new one using the refresh token. + const response = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: encodeQuery({ + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: "refresh_token", + refresh_token: config.refreshToken, + redirect_uri: redirectUrl, + scope: scopes.join(" "), + }), + }); + const data = await response.json(); + if ("error" in data) + throw new Error("error_description" in data ? data.error_description : "Unknown discord rpc refresh error"); + config.accessToken = data.access_token; + config.refreshToken = data.refresh_token; + config.expireTime = data.expires_in + Date.now() / 1000 - 1000; + } + return { + clientId: config.clientId, + clientSecret: config.clientSecret, + redirectUri: redirectUrl, + scopes: scopes, + accessToken: config.accessToken, + }; +} + +function encodeQuery(query: Record): URLSearchParams { + const params = new URLSearchParams(); + for (const key in query) { + params.append(key, String(query[key])); + } + return params; +} diff --git a/nodecg-io-discord-rpc/extension/index.ts b/nodecg-io-discord-rpc/extension/index.ts new file mode 100644 index 000000000..e6ea0c162 --- /dev/null +++ b/nodecg-io-discord-rpc/extension/index.ts @@ -0,0 +1,36 @@ +import { NodeCG } from "nodecg-types/types/server"; +import { Result, emptySuccess, success, ServiceBundle } from "nodecg-io-core"; +import { DiscordRpcConfig, createLoginData } from "./discordRpcAuth"; +import * as rpc from "discord-rpc"; + +export type DiscordRpcClient = rpc.Client; + +module.exports = (nodecg: NodeCG) => { + new DiscordRpcService(nodecg, "discord-rpc", __dirname, "../schema.json").register(); +}; + +class DiscordRpcService extends ServiceBundle { + async validateConfig(config: DiscordRpcConfig): Promise> { + const client = new rpc.Client({ transport: "ipc" }); + const login = await createLoginData(client, config, ["identify", "rpc"]); + await client.login(login); + await client.destroy(); + return emptySuccess(); + } + + async createClient(config: DiscordRpcConfig): Promise> { + const client = new rpc.Client({ transport: "ipc" }); + const login = await createLoginData(client, config, ["identify", "rpc"]); + await client.login(login); + this.nodecg.log.info("Successfully created discord-rpc client."); + return success(client); + } + + stopClient(client: DiscordRpcClient): void { + client.destroy().then((_) => this.nodecg.log.info("Successfully stopped discord-rpc client.")); + } + + removeHandlers(client: DiscordRpcClient): void { + client.removeAllListeners(); + } +} diff --git a/nodecg-io-discord-rpc/package.json b/nodecg-io-discord-rpc/package.json new file mode 100644 index 000000000..8c6822f4d --- /dev/null +++ b/nodecg-io-discord-rpc/package.json @@ -0,0 +1,50 @@ +{ + "name": "nodecg-io-discord-rpc", + "version": "0.2.0", + "description": "Allows to interface with a locally running discord client via RPC", + "homepage": "https://nodecg.io/RELEASE/samples/discord-rpc", + "author": { + "name": "noeppi_noeppi", + "url": "https://github.com/noeppi-noeppi" + }, + "repository": { + "type": "git", + "url": "https://github.com/codeoverflow-org/nodecg-io.git", + "directory": "nodecg-io-discord-rpc" + }, + "files": [ + "**/*.js", + "**/*.js.map", + "**/*.d.ts", + "*.json" + ], + "main": "extension/index", + "scripts": { + "build": "tsc -b", + "watch": "tsc -b -w", + "clean": "tsc -b --clean" + }, + "keywords": [ + "nodecg-io", + "nodecg-bundle" + ], + "nodecg": { + "compatibleRange": "^1.1.1", + "bundleDependencies": { + "nodecg-io-core": "^0.2.0" + } + }, + "license": "MIT", + "devDependencies": { + "@types/node": "^15.0.2", + "@types/node-fetch": "^2.5.10", + "nodecg-types": "^1.8.2", + "typescript": "^4.2.4" + }, + "dependencies": { + "nodecg-io-core": "^0.2.0", + "discord-rpc": "^4.0.1", + "@types/discord-rpc": "^4.0.0", + "node-fetch": "^2.6.1" + } +} diff --git a/nodecg-io-discord-rpc/schema.json b/nodecg-io-discord-rpc/schema.json new file mode 100644 index 000000000..8d790f894 --- /dev/null +++ b/nodecg-io-discord-rpc/schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string", + "description": "The client id of the discord app." + }, + "clientSecret": { + "type": "string", + "description": "The client secret of the discord app." + }, + "accessToken": { + "type": "string", + "description": "The client access token." + }, + "refreshToken": { + "type": "string", + "description": "The client refresh token." + }, + "redirectUrl": { + "type": "string", + "description": "The client redirect URL." + }, + "expireTime": { + "type": "number", + "description": "Timestamp when the access token expires." + } + }, + "required": ["clientId", "clientSecret"] +} diff --git a/nodecg-io-discord-rpc/tsconfig.json b/nodecg-io-discord-rpc/tsconfig.json new file mode 100644 index 000000000..1c8405620 --- /dev/null +++ b/nodecg-io-discord-rpc/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.common.json" +} diff --git a/package-lock.json b/package-lock.json index c96d299b9..5e0048ddf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,12 +4,14 @@ "requires": true, "packages": { "": { + "name": "nodecg-io", "dependencies": { "@octokit/rest": "^18.12.0", "@rauschma/stringio": "^1.4.0", "@serialport/parser-readline": "^9.0.7", "@slack/web-api": "^6.1.0", "@types/crypto-js": "^4.0.1", + "@types/discord-rpc": "^4.0.0", "@types/gapi": "^0.0.39", "@types/irc": "^0.5.0", "@types/jest": "^27.0.1", @@ -25,6 +27,7 @@ "clean-webpack-plugin": "^3.0.0", "crypto-js": "^4.0.0", "dbus-next": "^0.10.2", + "discord-rpc": "^4.0.1", "discord.js": "^12.5.3", "easymidi": "^2.0.4", "elgato-stream-deck": "^4.0.1", @@ -4003,6 +4006,11 @@ "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.0.2.tgz", "integrity": "sha512-sCVniU+h3GcGqxOmng11BRvf9TfN9yIs8KKjB8C8d75W69cpTfZG80gau9yTx5SxF3gvHGbJhdESzzvnjtf3Og==" }, + "node_modules/@types/discord-rpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/discord-rpc/-/discord-rpc-4.0.0.tgz", + "integrity": "sha512-a5HiKOcBkB43g/lN6fBYw8FyGc6Ue9CYucxxHxXlELXpb1CxCa2NA2pGK2Ub88pi4uY5+HQeSFbYtH6DJtV3Qw==" + }, "node_modules/@types/engine.io": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/engine.io/-/engine.io-3.1.7.tgz", @@ -7389,6 +7397,18 @@ "node": ">=8" } }, + "node_modules/discord-rpc": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/discord-rpc/-/discord-rpc-4.0.1.tgz", + "integrity": "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA==", + "dependencies": { + "node-fetch": "^2.6.1", + "ws": "^7.3.1" + }, + "optionalDependencies": { + "register-scheme": "github:devsnek/node-register-scheme" + } + }, "node_modules/discord.js": { "version": "12.5.3", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.5.3.tgz", @@ -16448,6 +16468,23 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/register-scheme": { + "version": "0.0.2", + "resolved": "git+ssh://git@github.com/devsnek/node-register-scheme.git#e7cc9a63a1f512565da44cb57316d9fb10750e17", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.3.0", + "node-addon-api": "^1.3.0" + } + }, + "node_modules/register-scheme/node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "optional": true + }, "node_modules/registry-auth-token": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz", @@ -23441,6 +23478,11 @@ "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.0.2.tgz", "integrity": "sha512-sCVniU+h3GcGqxOmng11BRvf9TfN9yIs8KKjB8C8d75W69cpTfZG80gau9yTx5SxF3gvHGbJhdESzzvnjtf3Og==" }, + "@types/discord-rpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/discord-rpc/-/discord-rpc-4.0.0.tgz", + "integrity": "sha512-a5HiKOcBkB43g/lN6fBYw8FyGc6Ue9CYucxxHxXlELXpb1CxCa2NA2pGK2Ub88pi4uY5+HQeSFbYtH6DJtV3Qw==" + }, "@types/engine.io": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/engine.io/-/engine.io-3.1.7.tgz", @@ -26190,6 +26232,16 @@ "path-type": "^4.0.0" } }, + "discord-rpc": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/discord-rpc/-/discord-rpc-4.0.1.tgz", + "integrity": "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA==", + "requires": { + "node-fetch": "^2.6.1", + "register-scheme": "github:devsnek/node-register-scheme", + "ws": "^7.3.1" + } + }, "discord.js": { "version": "12.5.3", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.5.3.tgz", @@ -33294,6 +33346,23 @@ "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true }, + "register-scheme": { + "version": "git+ssh://git@github.com/devsnek/node-register-scheme.git#e7cc9a63a1f512565da44cb57316d9fb10750e17", + "from": "register-scheme@github:devsnek/node-register-scheme", + "optional": true, + "requires": { + "bindings": "^1.3.0", + "node-addon-api": "^1.3.0" + }, + "dependencies": { + "node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "optional": true + } + } + }, "registry-auth-token": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz", diff --git a/samples/discord-rpc/extension/index.ts b/samples/discord-rpc/extension/index.ts new file mode 100644 index 000000000..f129050d3 --- /dev/null +++ b/samples/discord-rpc/extension/index.ts @@ -0,0 +1,17 @@ +import { NodeCG } from "nodecg-types/types/server"; +import { DiscordRpcClient } from "nodecg-io-discord-rpc"; +import { requireService } from "nodecg-io-core"; + +module.exports = function (nodecg: NodeCG) { + nodecg.log.info("Sample bundle for DiscordRpc started."); + + const discordRpc = requireService(nodecg, "discord-rpc"); + + discordRpc?.onAvailable((client) => { + nodecg.log.info("DiscordRpc service available. Username: " + client.user.username); + }); + + discordRpc?.onUnavailable(() => { + nodecg.log.info("DiscordRpc service unavailable."); + }); +}; diff --git a/samples/discord-rpc/package.json b/samples/discord-rpc/package.json new file mode 100644 index 000000000..6616e475c --- /dev/null +++ b/samples/discord-rpc/package.json @@ -0,0 +1,24 @@ +{ + "name": "discord-rpc", + "version": "0.2.0", + "private": true, + "nodecg": { + "compatibleRange": "^1.1.1", + "bundleDependencies": { + "nodecg-io-discord-rpc": "0.2.0" + } + }, + "scripts": { + "build": "tsc -b", + "watch": "tsc -b -w", + "clean": "tsc -b --clean" + }, + "license": "MIT", + "dependencies": { + "@types/node": "^15.0.2", + "nodecg-types": "^1.8.2", + "nodecg-io-core": "^0.2.0", + "typescript": "^4.2.4", + "nodecg-io-discord-rpc": "0.2.0" + } +} diff --git a/samples/discord-rpc/tsconfig.json b/samples/discord-rpc/tsconfig.json new file mode 100644 index 000000000..c8bb01bee --- /dev/null +++ b/samples/discord-rpc/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.common.json" +}