Skip to content
This repository was archived by the owner on Apr 13, 2025. It is now read-only.

Commit df701f1

Browse files
authored
Merge pull request #258 from noeppi-noeppi/discord-rpc
Add a discord rpc service
2 parents 7afbd88 + b93a37c commit df701f1

File tree

9 files changed

+329
-0
lines changed

9 files changed

+329
-0
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Required because discord-rpc does not expose the access token
2+
// and does not even use the refresh token.
3+
// Without this, users would be prompted on whether they agree, whenever
4+
// the service connects to discord.
5+
6+
import * as rpc from "discord-rpc";
7+
import fetch from "node-fetch";
8+
import { URLSearchParams } from "url";
9+
10+
// Defined here because nodejs does not seem to accept
11+
// circular imports.
12+
export interface DiscordRpcConfig {
13+
clientId: string;
14+
clientSecret: string;
15+
accessToken?: string;
16+
refreshToken?: string;
17+
redirectUrl?: string;
18+
expireTime?: number;
19+
}
20+
21+
// Fill the config values and create matching login data
22+
export async function createLoginData(
23+
client: rpc.Client,
24+
config: DiscordRpcConfig,
25+
scopes: string[],
26+
): Promise<rpc.RPCLoginOptions> {
27+
await client.connect(config.clientId);
28+
const redirectUrl = config.redirectUrl === undefined ? "http://127.0.0.1" : config.redirectUrl;
29+
if (config.accessToken === undefined || config.refreshToken === undefined || config.expireTime === undefined) {
30+
// Call authorize
31+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
32+
// @ts-ignore
33+
const authorizeResult = await client.request("AUTHORIZE", {
34+
client_id: config.clientId,
35+
scopes: scopes,
36+
});
37+
const response = await fetch("https://discord.com/api/oauth2/token", {
38+
method: "POST",
39+
headers: {
40+
"Content-Type": "application/x-www-form-urlencoded",
41+
},
42+
body: encodeQuery({
43+
client_id: config.clientId,
44+
client_secret: config.clientSecret,
45+
grant_type: "authorization_code",
46+
code: authorizeResult.code,
47+
redirect_uri: redirectUrl,
48+
scope: scopes.join(" "),
49+
}),
50+
});
51+
const data = await response.json();
52+
if ("error" in data)
53+
throw new Error("error_description" in data ? data.error_description : "Unknown discord rpc login error");
54+
config.accessToken = data.access_token;
55+
config.refreshToken = data.refresh_token;
56+
config.expireTime = data.expires_in + Date.now() / 1000 - 1000;
57+
} else if (config.expireTime < Date.now() / 1000) {
58+
// Token expired. Request a new one using the refresh token.
59+
const response = await fetch("https://discord.com/api/oauth2/token", {
60+
method: "POST",
61+
headers: {
62+
"Content-Type": "application/x-www-form-urlencoded",
63+
},
64+
body: encodeQuery({
65+
client_id: config.clientId,
66+
client_secret: config.clientSecret,
67+
grant_type: "refresh_token",
68+
refresh_token: config.refreshToken,
69+
redirect_uri: redirectUrl,
70+
scope: scopes.join(" "),
71+
}),
72+
});
73+
const data = await response.json();
74+
if ("error" in data)
75+
throw new Error("error_description" in data ? data.error_description : "Unknown discord rpc refresh error");
76+
config.accessToken = data.access_token;
77+
config.refreshToken = data.refresh_token;
78+
config.expireTime = data.expires_in + Date.now() / 1000 - 1000;
79+
}
80+
return {
81+
clientId: config.clientId,
82+
clientSecret: config.clientSecret,
83+
redirectUri: redirectUrl,
84+
scopes: scopes,
85+
accessToken: config.accessToken,
86+
};
87+
}
88+
89+
function encodeQuery(query: Record<string, unknown>): URLSearchParams {
90+
const params = new URLSearchParams();
91+
for (const key in query) {
92+
params.append(key, String(query[key]));
93+
}
94+
return params;
95+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { NodeCG } from "nodecg-types/types/server";
2+
import { Result, emptySuccess, success, ServiceBundle } from "nodecg-io-core";
3+
import { DiscordRpcConfig, createLoginData } from "./discordRpcAuth";
4+
import * as rpc from "discord-rpc";
5+
6+
export type DiscordRpcClient = rpc.Client;
7+
8+
module.exports = (nodecg: NodeCG) => {
9+
new DiscordRpcService(nodecg, "discord-rpc", __dirname, "../schema.json").register();
10+
};
11+
12+
class DiscordRpcService extends ServiceBundle<DiscordRpcConfig, DiscordRpcClient> {
13+
async validateConfig(config: DiscordRpcConfig): Promise<Result<void>> {
14+
const client = new rpc.Client({ transport: "ipc" });
15+
const login = await createLoginData(client, config, ["identify", "rpc"]);
16+
await client.login(login);
17+
await client.destroy();
18+
return emptySuccess();
19+
}
20+
21+
async createClient(config: DiscordRpcConfig): Promise<Result<DiscordRpcClient>> {
22+
const client = new rpc.Client({ transport: "ipc" });
23+
const login = await createLoginData(client, config, ["identify", "rpc"]);
24+
await client.login(login);
25+
this.nodecg.log.info("Successfully created discord-rpc client.");
26+
return success(client);
27+
}
28+
29+
stopClient(client: DiscordRpcClient): void {
30+
client.destroy().then((_) => this.nodecg.log.info("Successfully stopped discord-rpc client."));
31+
}
32+
33+
removeHandlers(client: DiscordRpcClient): void {
34+
client.removeAllListeners();
35+
}
36+
}

nodecg-io-discord-rpc/package.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "nodecg-io-discord-rpc",
3+
"version": "0.2.0",
4+
"description": "Allows to interface with a locally running discord client via RPC",
5+
"homepage": "https://nodecg.io/RELEASE/samples/discord-rpc",
6+
"author": {
7+
"name": "noeppi_noeppi",
8+
"url": "https://github.com/noeppi-noeppi"
9+
},
10+
"repository": {
11+
"type": "git",
12+
"url": "https://github.com/codeoverflow-org/nodecg-io.git",
13+
"directory": "nodecg-io-discord-rpc"
14+
},
15+
"files": [
16+
"**/*.js",
17+
"**/*.js.map",
18+
"**/*.d.ts",
19+
"*.json"
20+
],
21+
"main": "extension/index",
22+
"scripts": {
23+
"build": "tsc -b",
24+
"watch": "tsc -b -w",
25+
"clean": "tsc -b --clean"
26+
},
27+
"keywords": [
28+
"nodecg-io",
29+
"nodecg-bundle"
30+
],
31+
"nodecg": {
32+
"compatibleRange": "^1.1.1",
33+
"bundleDependencies": {
34+
"nodecg-io-core": "^0.2.0"
35+
}
36+
},
37+
"license": "MIT",
38+
"devDependencies": {
39+
"@types/node": "^15.0.2",
40+
"@types/node-fetch": "^2.5.10",
41+
"nodecg-types": "^1.8.2",
42+
"typescript": "^4.2.4"
43+
},
44+
"dependencies": {
45+
"nodecg-io-core": "^0.2.0",
46+
"discord-rpc": "^4.0.1",
47+
"@types/discord-rpc": "^4.0.0",
48+
"node-fetch": "^2.6.1"
49+
}
50+
}

nodecg-io-discord-rpc/schema.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"type": "object",
4+
"additionalProperties": false,
5+
"properties": {
6+
"clientId": {
7+
"type": "string",
8+
"description": "The client id of the discord app."
9+
},
10+
"clientSecret": {
11+
"type": "string",
12+
"description": "The client secret of the discord app."
13+
},
14+
"accessToken": {
15+
"type": "string",
16+
"description": "The client access token."
17+
},
18+
"refreshToken": {
19+
"type": "string",
20+
"description": "The client refresh token."
21+
},
22+
"redirectUrl": {
23+
"type": "string",
24+
"description": "The client redirect URL."
25+
},
26+
"expireTime": {
27+
"type": "number",
28+
"description": "Timestamp when the access token expires."
29+
}
30+
},
31+
"required": ["clientId", "clientSecret"]
32+
}

nodecg-io-discord-rpc/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "../tsconfig.common.json"
3+
}

package-lock.json

Lines changed: 69 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { NodeCG } from "nodecg-types/types/server";
2+
import { DiscordRpcClient } from "nodecg-io-discord-rpc";
3+
import { requireService } from "nodecg-io-core";
4+
5+
module.exports = function (nodecg: NodeCG) {
6+
nodecg.log.info("Sample bundle for DiscordRpc started.");
7+
8+
const discordRpc = requireService<DiscordRpcClient>(nodecg, "discord-rpc");
9+
10+
discordRpc?.onAvailable((client) => {
11+
nodecg.log.info("DiscordRpc service available. Username: " + client.user.username);
12+
});
13+
14+
discordRpc?.onUnavailable(() => {
15+
nodecg.log.info("DiscordRpc service unavailable.");
16+
});
17+
};

samples/discord-rpc/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "discord-rpc",
3+
"version": "0.2.0",
4+
"private": true,
5+
"nodecg": {
6+
"compatibleRange": "^1.1.1",
7+
"bundleDependencies": {
8+
"nodecg-io-discord-rpc": "0.2.0"
9+
}
10+
},
11+
"scripts": {
12+
"build": "tsc -b",
13+
"watch": "tsc -b -w",
14+
"clean": "tsc -b --clean"
15+
},
16+
"license": "MIT",
17+
"dependencies": {
18+
"@types/node": "^15.0.2",
19+
"nodecg-types": "^1.8.2",
20+
"nodecg-io-core": "^0.2.0",
21+
"typescript": "^4.2.4",
22+
"nodecg-io-discord-rpc": "0.2.0"
23+
}
24+
}

samples/discord-rpc/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "../../tsconfig.common.json"
3+
}

0 commit comments

Comments
 (0)