Add an option to use a specific Discord client

This commit is contained in:
Samuel Elliott 2022-05-14 21:27:00 +01:00
parent b531fcced4
commit 4bc27e3ca9
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
5 changed files with 260 additions and 17 deletions

View File

@ -58,6 +58,9 @@ export function builder(yargs: Argv<ParentArguments>) {
describe: 'Stay connected to Discord while not playing',
type: 'boolean',
default: false,
}).option('discord-user', {
describe: 'Discord user ID (choose which Discord client to use when multiple are available)',
type: 'string',
}).option('splatnet2-monitor', {
describe: 'Download new SplatNet 2 data when you are playing Splatoon 2 online',
type: 'boolean',
@ -142,6 +145,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE;
i.discord_preconnect = argv.discordPreconnect;
if (argv.discordUser) i.discord_client_filter = (client, id) => client.user.id === argv.discordUser;
if (argv.splatnet2Monitor) {
const storage = await initStorage(argv.dataPath);
@ -208,6 +212,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
i.presence_user = argv.friendNsaid ?? data?.nsoAccount.user.nsaId;
i.discord_preconnect = argv.discordPreconnect;
if (argv.discordUser) i.discord_client_filter = (client, id) => client.user.id === argv.discordUser;
console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
data.user.screenName, data.user.nickname, data.nsoAccount.user.name);

View File

@ -0,0 +1,43 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../../cli.js';
import { DiscordRpcClient, getAllIpcSockets } from '../../discord/rpc.js';
import { defaultTitle } from '../../discord/titles.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util.js';
const debug = createDebug('cli:util:discord-rpc');
debug.enabled = true;
export const command = 'discord-rpc';
export const desc = 'Search for Discord IPC sockets';
export function builder(yargs: Argv<ParentArguments>) {
return yargs;
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
const CLIENT_ID = defaultTitle.client;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const sockets = await getAllIpcSockets();
debug('Found %d Discord IPC sockets', sockets.length, sockets.map(s => s[0]));
for (const [id, socket] of sockets) {
const client = new DiscordRpcClient({ transport: 'ipc', ipc_socket: socket });
await client.connect(CLIENT_ID);
debug('[%d] Connected', id);
if (client.application) {
debug('[%d] Application', id, client.application);
}
if (client.user) {
debug('[%d] User', id, client.user);
debug('[%d] User avatar', id,
'https://cdn.discordapp.com/avatars/' + client.user.id + '/' + client.user.avatar + '.png');
}
await client.destroy();
}
}

View File

@ -2,3 +2,4 @@ export * as captureid from './captureid.js';
export * as validateDiscordTitles from './validate-discord-titles.js';
export * as exportDiscordTitles from './export-discord-titles.js';
export * as discordActivity from './discord-activity.js';
export * as discordRpc from './discord-rpc.js';

View File

@ -1,11 +1,11 @@
import createDebug from 'debug';
import DiscordRPC from 'discord-rpc';
import { ActiveEvent, CurrentUser, Friend, Presence, PresenceState, ZncErrorResponse } from '../api/znc-types.js';
import { Loop, LoopResult } from '../util.js';
import { DiscordRpcClient, findDiscordRpcClient } from '../discord/rpc.js';
import { DiscordPresencePlayTime, DiscordPresenceContext, getDiscordPresence, getInactiveDiscordPresence } from '../discord/util.js';
import { ZncNotifications } from './notify.js';
import { ErrorResponse } from '../api/util.js';
import { LoopResult } from '../util.js';
import { getPresenceFromUrl } from '../api/znc-proxy.js';
import { ActiveEvent, CurrentUser, Friend, Presence, PresenceState, ZncErrorResponse } from '../api/znc-types.js';
import { ErrorResponse } from '../api/util.js';
const debug = createDebug('nxapi:nso:presence');
const debugProxy = createDebug('nxapi:nso:presence:proxy');
@ -56,7 +56,8 @@ export class ZncDiscordPresence extends ZncNotifications {
return !!this.presence_user;
}
rpc: {client: DiscordRPC.Client, id: string} | null = null;
rpc: {client: DiscordRpcClient, id: string} | null = null;
discord_client_filter: ((client: DiscordRpcClient, id?: number) => boolean) | undefined = undefined;
title: {id: string; since: number} | null = null;
i = 0;
@ -82,11 +83,17 @@ export class ZncDiscordPresence extends ZncNotifications {
(online && 'name' in presence.game) ||
(this.show_console_online && presence?.state === PresenceState.INACTIVE);
if (this.rpc && this.discord_client_filter && !this.discord_client_filter.call(null, this.rpc.client)) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
if (!presence || !show_presence) {
if (this.presence_enabled && this.discord_preconnect && !this.rpc) {
debugDiscord('No presence but Discord preconnect enabled - connecting');
const discordpresence = getInactiveDiscordPresence(PresenceState.OFFLINE, 0);
const client = await this.createDiscordClient(discordpresence.id);
const client = await this.createDiscordClient(discordpresence.id, this.discord_client_filter);
this.rpc = {client, id: discordpresence.id};
return;
}
@ -124,7 +131,7 @@ export class ZncDiscordPresence extends ZncNotifications {
}
if (!this.rpc) {
const client = await this.createDiscordClient(discordpresence.id);
const client = await this.createDiscordClient(discordpresence.id, this.discord_client_filter);
this.rpc = {client, id: discordpresence.id};
}
@ -145,19 +152,22 @@ export class ZncDiscordPresence extends ZncNotifications {
this.update_presence_errors = 0;
}
async createDiscordClient(clientid: string) {
let client: DiscordRPC.Client;
async createDiscordClient(
clientid: string,
filter = (client: DiscordRpcClient, id: number) => true
) {
let client: DiscordRpcClient;
let attempts = 0;
let connected = false;
let id;
while (attempts < 10) {
if (attempts === 0) debugDiscord('RPC connecting', clientid);
else debugDiscord('RPC connecting, attempt %d', attempts + 1, clientid);
try {
client = new DiscordRPC.Client({transport: 'ipc'});
await client.connect(clientid);
debugDiscord('RPC connected', clientid, client.application, client.user);
[id, client] = await findDiscordRpcClient(clientid, filter);
debugDiscord('RPC connected', id, clientid, client.application, client.user);
connected = true;
break;
} catch (err) {}
@ -180,15 +190,13 @@ export class ZncDiscordPresence extends ZncNotifications {
debugDiscord('RPC reconnecting, attempt %d', attempts + 1, clientid);
try {
const newclient = new DiscordRPC.Client({transport: 'ipc'});
await newclient.connect(clientid);
debugDiscord('RPC reconnected', clientid, newclient.application, newclient.user);
[id, client] = await findDiscordRpcClient(clientid, filter);
debugDiscord('RPC reconnected', id, clientid, client.application, client.user);
// @ts-expect-error
client.transport.on('close', reconnect);
this.rpc.client = newclient;
client = newclient;
this.rpc.client = client;
connected = true;
break;
} catch (err) {

186
src/discord/rpc.ts Normal file
View File

@ -0,0 +1,186 @@
import * as net from 'net';
import { createRequire } from 'module';
import createDebug from 'debug';
import fetch from 'node-fetch';
import DiscordRPC from 'discord-rpc';
const debug = createDebug('nxapi:discord:rpc');
const require = createRequire(import.meta.url);
const BaseIpcTransport = require('discord-rpc/src/transports/ipc.js');
export async function getDiscordRpcClients() {
const sockets = await getAllIpcSockets();
return sockets.map(s => new DiscordRpcClient({transport: 'ipc', ipc_socket: s[1]}));
}
export async function findDiscordRpcClient(
clientid: string, filter: (client: DiscordRpcClient, id: number) => boolean
) {
for (let i = 0; i < 10; i++) {
const socket = await getIpcSocket(i);
if (!socket) continue;
const client = new DiscordRpcClient({transport: 'ipc', ipc_socket: socket});
await client.connect(clientid);
try {
if (filter.call(null, client, i)) return [i, client] as const;
await client.destroy();
} catch (err) {
await client.destroy();
throw err;
}
}
throw new Error('Failed to find a matching Discord client');
}
//
// Patches discord-rpc to allow using a specific socket.
//
export class DiscordRpcClient extends DiscordRPC.Client {
constructor(options?: DiscordRPC.RPCClientOptions & {
ipc_socket?: net.Socket;
}) {
super({
transport: 'ipc',
...options,
});
if (options?.transport ?? 'ipc' === 'ipc') {
// @ts-expect-error
this.transport = new IpcTransport(this);
// @ts-expect-error
if (options?.ipc_socket) this.transport.socket = options?.ipc_socket;
// @ts-expect-error
this.transport.on('message', this._onRpcMessage.bind(this));
}
}
}
class IpcTransport extends BaseIpcTransport {
client!: DiscordRpcClient;
socket!: net.Socket | null;
onClose!: () => void;
async connect() {
const socket = this.socket = this.socket ?? await getIpc();
socket.on('close', this.onClose.bind(this));
socket.on('error', this.onClose.bind(this));
this.emit('open');
socket.write(BaseIpcTransport.encode(OPCode.HANDSHAKE, {
v: 1,
// @ts-expect-error
client_id: this.client.clientId,
}));
socket.pause();
socket.on('readable', () => {
// @ts-expect-error
BaseIpcTransport.decode(socket, ({ op, data }) => {
switch (op) {
case OPCode.PING:
this.send(data, OPCode.PONG);
break;
case OPCode.FRAME:
if (!data) {
return;
}
if (data.cmd === 'AUTHORIZE' && data.evt !== 'ERROR') {
findEndpoint().then(endpoint => {
// @ts-expect-error
this.client.request.endpoint = endpoint;
}).catch(e => this.client.emit('error', e));
}
// a@ts-expect-error
this.emit('message', data);
break;
case OPCode.CLOSE:
// a@ts-expect-error
this.emit('close', data);
break;
default:
break;
}
});
});
}
}
enum OPCode {
HANDSHAKE = 0,
FRAME = 1,
CLOSE = 2,
PING = 3,
PONG = 4,
}
function getIpcPath(id: number) {
if (process.platform === 'win32') {
return `\\\\?\\pipe\\discord-ipc-${id}`;
}
const { env: { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP } } = process;
const prefix = XDG_RUNTIME_DIR || TMPDIR || TMP || TEMP || '/tmp';
return `${prefix.replace(/\/$/, '')}/discord-ipc-${id}`;
}
export function getIpc(id = 0) {
return new Promise<net.Socket>((resolve, reject) => {
const path = getIpcPath(id);
const onerror = () => {
if (id < 10) {
resolve(getIpc(id + 1));
} else {
reject(new Error('Could not connect'));
}
};
const sock = net.createConnection(path, () => {
sock.removeListener('error', onerror);
resolve(sock);
});
sock.once('error', onerror);
});
}
export function getIpcSocket(id: number) {
return new Promise<net.Socket | null>((resolve, reject) => {
const path = getIpcPath(id);
const onerror = () => resolve(null);
const sock = net.createConnection(path, () => {
sock.removeListener('error', onerror);
resolve(sock);
});
sock.once('error', onerror);
});
}
export function getAllIpcSockets() {
const promises: Promise<[number, net.Socket | null]>[] = [];
for (let i = 0; i < 10; i++) {
promises.push(getIpcSocket(i).then(s => [i, s]));
}
return Promise.all(promises).then(s => s.filter(s => s[1]) as [number, net.Socket][]);
}
async function findEndpoint(tries = 0): Promise<string> {
if (tries > 30) {
throw new Error('Could not find endpoint');
}
const endpoint = `http://127.0.0.1:${6463 + (tries % 10)}`;
try {
const r = await fetch(endpoint);
if (r.status === 404) {
return endpoint;
}
return findEndpoint(tries + 1);
} catch (e) {
return findEndpoint(tries + 1);
}
}