nxapi/src/cli/nso/presence.ts
2022-04-08 19:57:45 +01:00

444 lines
17 KiB
TypeScript

import * as path from 'path';
import createDebug from 'debug';
import persist from 'node-persist';
import DiscordRPC from 'discord-rpc';
import fetch from 'node-fetch';
import { ActiveEvent, CurrentUser, Presence, PresenceState, ZncErrorResponse } from '../../api/znc-types.js';
import ZncApi from '../../api/znc.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, LoopResult, SavedToken, YargsArguments } from '../../util.js';
import { DiscordPresenceContext, getDiscordPresence, getInactiveDiscordPresence } from '../../discord/util.js';
import { handleEnableSplatNet2Monitoring, ZncNotifications } from './notify.js';
import { ErrorResponse } from '../../index.js';
const debug = createDebug('cli:nso:presence');
const debugProxy = createDebug('cli:nso:presence:proxy');
const debugDiscord = createDebug('cli:nso:presence:discordrpc');
export const command = 'presence';
export const desc = 'Start Discord Rich Presence';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('show-inactive-presence', {
describe: 'Show Discord presence if your console is online but you are not playing (only enable if you are the only user on all consoles your account exists on)',
type: 'boolean',
default: false,
}).option('show-event', {
describe: 'Show event (Online Lounge/voice chat) details - this shows the number of players in game (experimental)',
type: 'boolean',
default: false,
}).option('friend-nsaid', {
alias: ['friend-naid'],
describe: 'Friend\'s Nintendo Switch account ID',
type: 'string',
}).option('friend-code', {
describe: 'Friend code',
type: 'string',
}).option('user-notifications', {
describe: 'Show notification for your own user',
type: 'boolean',
default: false,
}).option('friend-notifications', {
describe: 'Show notification for friends',
type: 'boolean',
default: false,
}).option('update-interval', {
describe: 'Update interval in seconds',
type: 'number',
default: 30,
}).option('presence-url', {
describe: 'URL to get user presence from, for use with `nxapi nso http-server`',
type: 'string',
}).option('splatnet2-monitor', {
describe: 'Download new SplatNet 2 data when you are playing Splatoon 2 online',
type: 'boolean',
default: false,
}).option('splatnet2-monitor-directory', {
alias: ['sn2-path'],
describe: 'Directory to write SplatNet 2 record data to',
type: 'string',
}).option('splatnet2-monitor-profile-image', {
alias: ['sn2-profile-image'],
describe: 'Include profile image',
type: 'boolean',
default: false,
}).option('splatnet2-monitor-favourite-stage', {
alias: ['sn2-favourite-stage'],
describe: 'Favourite stage to include on profile image',
type: 'string',
}).option('splatnet2-monitor-favourite-colour', {
alias: ['sn2-favourite-colour'],
describe: 'Favourite colour to include on profile image',
type: 'string',
}).option('splatnet2-monitor-battles', {
alias: ['sn2-battles'],
describe: 'Include regular/ranked/private/festival battle results',
type: 'boolean',
default: true,
}).option('splatnet2-monitor-battle-summary-image', {
alias: ['sn2-battle-summary-image'],
describe: 'Include regular/ranked/private/festival battle summary image',
type: 'boolean',
default: false,
}).option('splatnet2-monitor-battle-images', {
alias: ['sn2-battle-images'],
describe: 'Include regular/ranked/private/festival battle result images',
type: 'boolean',
default: false,
}).option('splatnet2-monitor-coop', {
alias: ['sn2-coop'],
describe: 'Include coop (Salmon Run) results',
type: 'boolean',
default: true,
}).option('splatnet2-monitor-update-interval', {
alias: ['sn2-update-interval'],
describe: 'Update interval in seconds',
type: 'number',
// 3 minutes - the monitor is only active while the authenticated user is playing Splatoon 2 online
default: 3 * 60,
}).option('splatnet2-auto-update-session', {
alias: ['sn2-auto-update-session'],
describe: 'Automatically obtain and refresh the iksm_session cookie',
type: 'boolean',
default: true,
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
if (argv.presenceUrl) {
if (argv.showEvent) throw new Error('--presence-url not compatible with --show-event');
if (argv.friendNsaid) throw new Error('--presence-url not compatible with --friend-nsaid');
if (argv.userNotifications) throw new Error('--presence-url not compatible with --user-notifications');
if (argv.friendNotifications) throw new Error('--presence-url not compatible with --user-notifications');
const i = new ZncProxyDiscordPresence(argv, argv.presenceUrl);
if (argv.splatnet2Monitor) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid);
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
console.warn('SplatNet 2 monitoring is enabled for %s (NA %s, NSO %s), but using znc proxy for ' +
'presence. The presence URL must return the presence of the authenticated user for SplatNet 2 ' +
'monitoring to work.',
data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
i.splatnet2_monitors.set(argv.presenceUrl, handleEnableSplatNet2Monitoring(argv, storage, token));
} else {
if (argv.user) throw new Error('--presence-url not compatible with --user');
if (argv.token) throw new Error('--presence-url not compatible with --token');
console.warn('Not authenticated; using znc proxy');
}
await i.init();
while (true) {
await i.loop();
}
return;
}
if (argv.showEvent && argv.friendNsaid) throw new Error('--show-event not compatible with --friend-nsaid');
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid);
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
const i = new ZncDiscordPresence(argv, storage, token, nso, data);
console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
if (argv.splatnet2Monitor) {
if (argv.friendNsaid) {
console.warn('SplatNet 2 monitoring is enabled, but --friend-nsaid is set. SplatNet 2 records will only be downloaded when the authenticated user is playing Splatoon 2 online, regardless of the --friend-nsaid user.');
}
i.splatnet2_monitors.set(data.nsoAccount.user.nsaId, handleEnableSplatNet2Monitoring(argv, storage, token));
}
await i.init();
while (true) {
await i.loop();
}
}
export class ZncDiscordPresence extends ZncNotifications {
presence_user: string | null;
show_friend_code = false;
force_friend_code: CurrentUser['links']['friendCode'] | undefined = undefined;
show_console_online = false;
show_active_event = true;
constructor(
argv: Pick<ArgumentsCamelCase<Arguments>,
'userNotifications' | 'friendNotifications' | 'updateInterval' |
'friendCode' | 'showInactivePresence' | 'showEvent' | 'friendNsaid'
>,
storage: persist.LocalStorage,
token: string,
nso: ZncApi,
data: Omit<SavedToken, 'expires_at'>,
) {
super(argv, storage, token, nso, data);
let match;
this.force_friend_code =
(match = (argv.friendCode as string)?.match(/^(SW-)?(\d{4})-?(\d{4})-?(\d{4})$/)) ?
{id: match[2] + '-' + match[3] + '-' + match[4], regenerable: false, regenerableAt: 0} : undefined;
this.show_friend_code = !!this.force_friend_code || argv.friendCode === '' || argv.friendCode === '-';
this.show_console_online = argv.showInactivePresence;
this.show_active_event = argv.showEvent;
this.presence_user = argv.friendNsaid ?? data.nsoAccount.user.nsaId;
}
async init() {
const {friends, user, activeevent} = await this.fetch([
'announcements',
this.presence_user ?
this.presence_user === this.data.nsoAccount.user.nsaId ? 'user' :
{friend: this.presence_user, presence: true} : null,
this.presence_user && this.show_active_event ? 'event' : null,
this.user_notifications ? 'user' : null,
this.friend_notifications ? 'friends' : null,
this.splatnet2_monitors.size ? 'user' : null,
]);
if (this.presence_user) {
if (this.presence_user !== this.data.nsoAccount.user.nsaId) {
const friend = friends!.find(f => f.nsaId === this.presence_user);
if (!friend) {
throw new Error('User "' + this.presence_user + '" is not friends with this user');
}
await this.updatePresenceForDiscord(friend.presence);
} else {
await this.updatePresenceForDiscord(user!.presence, user!.links.friendCode, activeevent);
}
}
await this.updatePresenceForNotifications(user, friends);
await this.updatePresenceForSplatNet2Monitors([user!]);
await new Promise(rs => setTimeout(rs, this.update_interval * 1000));
}
rpc: {client: DiscordRPC.Client, id: string} | null = null;
title: {id: string; since: number} | null = null;
i = 0;
last_presence: Presence | null = null;
friendcode: CurrentUser['links']['friendCode'] | undefined = undefined;
last_event: ActiveEvent | undefined = undefined;
async updatePresenceForDiscord(
presence: Presence | null, friendcode?: CurrentUser['links']['friendCode'],
activeevent?: ActiveEvent
) {
this.last_presence = presence;
this.friendcode = friendcode;
this.last_event = activeevent;
const online = presence?.state === PresenceState.ONLINE || presence?.state === PresenceState.PLAYING;
const show_presence =
(online && 'name' in presence.game) ||
(this.show_console_online && presence?.state === PresenceState.INACTIVE);
if (!presence || !show_presence) {
if (this.rpc) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
this.title = null;
return;
}
const presencecontext: DiscordPresenceContext = {
friendcode: this.show_friend_code ? this.force_friend_code ?? friendcode : undefined,
activeevent,
znc_discord_presence: this,
nsaid: this.presence_user!,
};
const discordpresence = 'name' in presence.game ?
getDiscordPresence(presence.state, presence.game, presencecontext) :
getInactiveDiscordPresence(presence.state, presence.logoutAt, presencecontext);
if (this.rpc && this.rpc.id !== discordpresence.id) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
if (!this.rpc) {
const client = new DiscordRPC.Client({transport: 'ipc'});
let attempts = 0;
let connected = false;
while (attempts < 10) {
if (attempts === 0) debugDiscord('RPC connecting');
else debugDiscord('RPC connecting, attempt %d', attempts + 1);
try {
await client.connect(discordpresence.id);
debugDiscord('RPC connected');
connected = true;
break;
} catch (err) {}
attempts++;
await new Promise(rs => setTimeout(rs, 5000));
}
if (!connected) throw new Error('Failed to connect to Discord');
// @ts-expect-error
client.transport.on('close', async () => {
if (this.rpc?.client !== client) return;
debugDiscord('RPC client disconnected, attempting to reconnect');
let attempts = 0;
let connected = false;
while (attempts < 10) {
if (this.rpc?.client !== client) return;
debugDiscord('RPC reconnecting, attempt %d', attempts + 1);
try {
await client.connect(discordpresence.id);
debugDiscord('RPC reconnected');
connected = true;
break;
} catch (err) {}
attempts++;
await new Promise(rs => setTimeout(rs, 5000));
}
if (!connected) throw new Error('Failed to reconnect to Discord');
throw new Error('Discord disconnected');
});
this.rpc = {client, id: discordpresence.id};
}
if (discordpresence.title) {
if (discordpresence.title !== this.title?.id) {
this.title = {id: discordpresence.title, since: Date.now()};
}
if (discordpresence.showTimestamp) {
discordpresence.activity.startTimestamp = this.title.since;
}
} else {
this.title = null;
}
this.rpc.client.setActivity(discordpresence.activity);
this.update_presence_errors = 0;
}
async update() {
const {friends, user, activeevent} = await this.fetch([
this.presence_user ?
this.presence_user === this.data.nsoAccount.user.nsaId ? 'user' :
{friend: this.presence_user, presence: true} : null,
this.presence_user && this.show_active_event ? 'event' : null,
this.user_notifications ? 'user' : null,
this.friend_notifications ? 'friends' : null,
this.splatnet2_monitors.size ? 'user' : null,
]);
if (this.presence_user) {
if (this.presence_user !== this.data.nsoAccount.user.nsaId) {
const friend = friends!.find(f => f.nsaId === this.presence_user);
if (!friend) {
// Is the authenticated user no longer friends with this user?
await this.updatePresenceForDiscord(null);
} else {
await this.updatePresenceForDiscord(friend.presence);
}
} else {
await this.updatePresenceForDiscord(user!.presence, user!.links.friendCode, activeevent);
}
}
await this.updatePresenceForNotifications(user, friends);
await this.updatePresenceForSplatNet2Monitors([user!]);
}
update_presence_errors = 0;
async handleError(err: ErrorResponse<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
this.update_presence_errors++;
if (this.update_presence_errors > 2) {
// Disconnect from Discord if the last two attempts to update presence failed
// This prevents the user's activity on Discord being stuck
if (this.rpc) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
this.title = null;
}
return super.handleError(err);
}
}
export class ZncProxyDiscordPresence extends ZncDiscordPresence {
constructor(
readonly argv: ArgumentsCamelCase<Arguments>,
public presence_url: string
) {
super(argv, null!, null!, null!, null!);
}
async init() {
await this.update();
await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000));
}
async update() {
const response = await fetch(this.presence_url);
debugProxy('fetch %s %s, response %s', 'GET', this.presence_url, response.status);
if (response.status !== 200) {
console.error('Non-200 status code', await response.text());
throw new Error('Unknown error');
}
const presence = await response.json() as Presence;
await this.updatePresenceForDiscord(presence);
await this.updatePresenceForSplatNet2Monitor(presence, this.presence_url);
}
}