From 085d8a6a547524b064956154cd823254c6ee6279 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Fri, 25 Mar 2022 18:55:03 +0000 Subject: [PATCH] Use a single loop class for nso notify/presence and splatnet2 monitor --- src/cli/nso/http-server.ts | 22 ------ src/cli/nso/notify.ts | 87 ++++++++++++++---------- src/cli/nso/presence.ts | 127 ++++++++++++++++++++++------------- src/cli/splatnet2/monitor.ts | 34 +++++----- src/util.ts | 38 +++++++++++ 5 files changed, 188 insertions(+), 120 deletions(-) diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 306e7c7..98a5484 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -530,30 +530,8 @@ class ZncPresenceEventStream extends ZncNotifications { ) { super({ ...argv, - user: undefined, - token: undefined, - 'user-notifications': user, userNotifications: user, - 'friend-notifications': friend, friendNotifications: friend, - 'splatnet2-monitor-directory': undefined, - splatnet2MonitorDirectory: undefined, - 'splatnet2-monitor-profile-image': false, - splatnet2MonitorProfileImage: false, - 'splatnet2-monitor-favourite-stage': undefined, - splatnet2MonitorFavouriteStage: undefined, - 'splatnet2-monitor-favourite-colour': undefined, - splatnet2MonitorFavouriteColour: undefined, - 'splatnet2-monitor-battles': false, - splatnet2MonitorBattles: false, - 'splatnet2-monitor-battle-summary-image': false, - splatnet2MonitorBattleSummaryImage: false, - 'splatnet2-monitor-battle-images': false, - splatnet2MonitorBattleImages: false, - 'splatnet2-monitor-coop': false, - splatnet2MonitorCoop: false, - 'splatnet2-monitor-update-interval': 180, - splatnet2MonitorUpdateInterval: 180, }, storage, token, nso, data); } diff --git a/src/cli/nso/notify.ts b/src/cli/nso/notify.ts index abc843b..1ce4683 100644 --- a/src/cli/nso/notify.ts +++ b/src/cli/nso/notify.ts @@ -1,13 +1,15 @@ import createDebug from 'debug'; import persist from 'node-persist'; import notifier from 'node-notifier'; -import { CurrentUser, Friend, Game, Presence, PresenceState, ZncSuccessResponse } from '../../api/znc-types.js'; +import * as path from 'path'; +import { CurrentUser, Friend, Game, Presence, PresenceState, ZncErrorResponse, ZncSuccessResponse } from '../../api/znc-types.js'; import ZncApi from '../../api/znc.js'; import type { Arguments as ParentArguments } from '../nso.js'; -import { ArgumentsCamelCase, Argv, getTitleIdFromEcUrl, getToken, hrduration, initStorage, SavedToken, YargsArguments } from '../../util.js'; +import { ArgumentsCamelCase, Argv, getTitleIdFromEcUrl, getToken, hrduration, initStorage, Loop, LoopResult, SavedToken, YargsArguments } from '../../util.js'; import ZncProxyApi from '../../api/znc-proxy.js'; import { SplatNet2RecordsMonitor } from '../splatnet2/monitor.js'; import { getIksmToken } from '../splatnet2/util.js'; +import { ErrorResponse } from '../../api/util.js'; const debug = createDebug('cli:nso:notify'); const debugFriends = createDebug('cli:nso:notify:friends'); @@ -105,20 +107,30 @@ export async function handler(argv: ArgumentsCamelCase) { } } -export class ZncNotifications { +export class ZncNotifications extends Loop { splatnet2_monitors = new Map Promise)>(); + user_notifications = true; + friend_notifications = true; + update_interval = 30; + constructor( - readonly argv: ArgumentsCamelCase, + argv: Pick, 'userNotifications' | 'friendNotifications' | 'updateInterval'>, public storage: persist.LocalStorage, public token: string, public nso: ZncApi, public data: Omit, - ) {} + ) { + super(); + + this.user_notifications = argv.userNotifications; + this.friend_notifications = argv.friendNotifications; + this.update_interval = argv.updateInterval; + } async init() { const announcements = await this.nso.getAnnouncements(); - const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ? + const friends = this.friend_notifications || !(this.nso instanceof ZncProxyApi) ? await this.nso.getFriendList() : {result: {friends: []}}; if (!(this.nso instanceof ZncProxyApi)) { const webservices = await this.nso.getWebServices(); @@ -127,22 +139,22 @@ export class ZncNotifications { let user: ZncSuccessResponse | null = null; - if (this.argv.userNotifications) { + if (this.user_notifications) { user = await this.nso.getCurrentUser(); - await this.updateFriendsStatusForNotifications(this.argv.friendNotifications ? + await this.updateFriendsStatusForNotifications(this.friend_notifications ? [user.result, ...friends.result.friends] : [user.result]); - } else if (this.argv.friendNotifications) { + } else if (this.friend_notifications) { await this.updateFriendsStatusForNotifications(friends.result.friends); } - if (this.argv.splatnet2MonitorDirectory) { + if (this.splatnet2_monitors.size) { if (!user) user = await this.nso.getCurrentUser(); await this.updatePresenceForSplatNet2Monitors([user.result]); } - await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000)); + await new Promise(rs => setTimeout(rs, this.update_interval * 1000)); } onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) { @@ -334,13 +346,13 @@ export class ZncNotifications { async update() { debug('Updating presence'); - if (this.argv.friendNotifications) { + if (this.friend_notifications) { if (!(this.nso instanceof ZncProxyApi)) await this.nso.getActiveEvent(); - const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ? + const friends = this.friend_notifications || !(this.nso instanceof ZncProxyApi) ? await this.nso.getFriendList() : {result: {friends: []}}; if (!(this.nso instanceof ZncProxyApi)) await this.nso.getWebServices(); - if (this.argv.userNotifications) { + if (this.user_notifications) { const user = await this.nso.getCurrentUser(); await this.updateFriendsStatusForNotifications([user.result, ...friends.result.friends]); @@ -356,28 +368,31 @@ export class ZncNotifications { debug('Updated presence'); } - async loop() { - try { - await this.update(); + async handleError(err: ErrorResponse | NodeJS.ErrnoException): Promise { + if (err && 'response' in err && err.data?.status === 9404) { + // Token expired + debug('Renewing token'); - await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000)); - } catch (err) { - // @ts-expect-error - if (err?.data?.status === 9404) { - // Token expired - debug('Renewing token'); + const data = await this.nso.renewToken(this.token); - const data = await this.nso.renewToken(this.token); + const existingToken: SavedToken = { + ...data, + expires_at: Date.now() + (data.credential.expiresIn * 1000), + }; - const existingToken: SavedToken = { - ...data, - expires_at: Date.now() + (data.credential.expiresIn * 1000), - }; + await this.storage.setItem('NsoToken.' + this.token, existingToken); - await this.storage.setItem('NsoToken.' + this.token, existingToken); - } else { - throw err; - } + return LoopResult.OK_SKIP_INTERVAL; + } else if ('code' in err && (err as any).type === 'system' && err.code === 'ETIMEDOUT') { + debug('Request timed out, waiting %ds before retrying', this.update_interval, err); + + return LoopResult.OK; + } else if ('code' in err && (err as any).type === 'system' && err.code === 'ENOTFOUND') { + debug('Request error, waiting %ds before retrying', this.update_interval, err); + + return LoopResult.OK; + } else { + throw err; } } } @@ -415,8 +430,12 @@ export class EmbeddedSplatNet2Monitor extends SplatNet2RecordsMonitor { await this.loop(); } - // Run one more time after the loop ends - await this.loop(); + if (this._running === 0) { + // Run one more time after the loop ends + const result = await this.loopRun(); + } + + debugSplatnet2('SplatNet 2 monitoring finished'); } finally { this._running = 0; } diff --git a/src/cli/nso/presence.ts b/src/cli/nso/presence.ts index a413c7d..2ec04ad 100644 --- a/src/cli/nso/presence.ts +++ b/src/cli/nso/presence.ts @@ -2,13 +2,14 @@ import createDebug from 'debug'; import persist from 'node-persist'; import DiscordRPC from 'discord-rpc'; import fetch from 'node-fetch'; -import { CurrentUser, Friend, Presence, PresenceState, ZncSuccessResponse } from '../../api/znc-types.js'; +import { CurrentUser, Friend, Presence, PresenceState, ZncErrorResponse, ZncSuccessResponse } from '../../api/znc-types.js'; import ZncApi from '../../api/znc.js'; import type { Arguments as ParentArguments } from '../nso.js'; -import { ArgumentsCamelCase, Argv, getToken, initStorage, SavedToken, YargsArguments } from '../../util.js'; +import { ArgumentsCamelCase, Argv, getToken, initStorage, LoopResult, SavedToken, YargsArguments } from '../../util.js'; import { getDiscordPresence, getInactiveDiscordPresence } from '../../discord/util.js'; import { handleEnableSplatNet2Monitoring, ZncNotifications } from './notify.js'; import ZncProxyApi from '../../api/znc-proxy.js'; +import { ErrorResponse } from '../../index.js'; const debug = createDebug('cli:nso:presence'); const debugProxy = createDebug('cli:nso:presence:proxy'); @@ -28,7 +29,8 @@ export function builder(yargs: Argv) { 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('friend-naid', { + }).option('friend-nsaid', { + alias: ['friend-naid'], describe: 'Friend\'s Nintendo Switch account ID', type: 'string', }).option('friend-code', { @@ -90,7 +92,7 @@ type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { if (argv.presenceUrl) { - if (argv.friendNaid) throw new Error('--presence-url not compatible with --friend-naid'); + 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'); @@ -141,8 +143,8 @@ export async function handler(argv: ArgumentsCamelCase) { data.user.screenName, data.user.nickname, data.nsoAccount.user.name); if (argv.splatnet2MonitorDirectory) { - if (argv.friendNaid) { - console.warn('SplatNet 2 monitoring is enabled, but --friend-naid is set. SplatNet 2 records will only be downloaded when the authenticated user is playing Splatoon 2 online, regardless of the --friend-naid user.'); + 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)); @@ -156,10 +158,14 @@ export async function handler(argv: ArgumentsCamelCase) { } class ZncDiscordPresence extends ZncNotifications { - forceFriendCode: CurrentUser['links']['friendCode'] | undefined; + show_friend_code = false; + force_friend_code: CurrentUser['links']['friendCode'] | undefined = undefined; + show_console_online = false; + + friend_nsaid: string | null; constructor( - readonly argv: ArgumentsCamelCase, + argv: ArgumentsCamelCase, storage: persist.LocalStorage, token: string, nso: ZncApi, @@ -168,25 +174,29 @@ class ZncDiscordPresence extends ZncNotifications { super(argv, storage, token, nso, data); let match; - this.forceFriendCode = - (match = (this.argv.friendCode as string)?.match(/^(SW-)?(\d{4})-?(\d{4})-?(\d{4})$/)) ? + 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.friend_nsaid = argv.friendNsaid ?? null; } async init() { const announcements = await this.nso.getAnnouncements(); - const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ? - await this.nso.getFriendList() : {result: {friends: this.argv.friendNaid ? [this.argv.userNotifications ? - (await this.nso.fetch<{friend: Friend}>('/friend/' + this.argv.friendNaid)).friend : { + const friends = this.friend_notifications || !(this.nso instanceof ZncProxyApi) ? + await this.nso.getFriendList() : {result: {friends: this.friend_nsaid ? [this.user_notifications ? + (await this.nso.fetch<{friend: Friend}>('/friend/' + this.friend_nsaid)).friend : { id: 0, - nsaId: this.argv.friendNaid, + nsaId: this.friend_nsaid, imageUri: '', name: '', isFriend: true, isFavoriteFriend: false, isServiceUser: true, friendCreatedAt: 0, - presence: await this.nso.fetch('/friend/' + this.argv.friendNaid + '/presence'), + presence: await this.nso.fetch('/friend/' + this.friend_nsaid + '/presence'), }] : []}}; if (!(this.nso instanceof ZncProxyApi)) { await this.nso.getWebServices(); @@ -195,19 +205,19 @@ class ZncDiscordPresence extends ZncNotifications { let user: ZncSuccessResponse | null = null; - if (this.argv.friendNaid) { - const friend = friends.result.friends.find(f => f.nsaId === this.argv.friendNaid); + if (this.friend_nsaid) { + const friend = friends.result.friends.find(f => f.nsaId === this.friend_nsaid); if (!friend) { - throw new Error('User "' + this.argv.friendNaid + '" is not friends with this user'); + throw new Error('User "' + this.friend_nsaid + '" is not friends with this user'); } - if (this.argv.userNotifications && this.argv.friendNotifications) { + if (this.user_notifications && this.friend_notifications) { await this.updateFriendsStatusForNotifications(friends.result.friends, true); - } else if (this.argv.friendNotifications) { + } else if (this.friend_notifications) { await this.updateFriendsStatusForNotifications( - friends.result.friends.filter(f => f.nsaId !== this.argv.friendNaid), true); - } else if (this.argv.userNotifications && friend) { + friends.result.friends.filter(f => f.nsaId !== this.friend_nsaid), true); + } else if (this.user_notifications && friend) { await this.updateFriendsStatusForNotifications([friend], true); } @@ -215,23 +225,23 @@ class ZncDiscordPresence extends ZncNotifications { } else { user = await this.nso.getCurrentUser(); - if (this.argv.friendNotifications) { - await this.updateFriendsStatusForNotifications(this.argv.userNotifications ? + if (this.friend_notifications) { + await this.updateFriendsStatusForNotifications(this.user_notifications ? [user.result, ...friends.result.friends] : friends.result.friends); - } else if (this.argv.userNotifications) { + } else if (this.user_notifications) { await this.updateFriendsStatusForNotifications([user.result]); } await this.updatePresence(user.result.presence, user.result.links.friendCode); } - if (this.argv.splatnet2MonitorDirectory) { + if (this.splatnet2_monitors.size) { if (!user) user = await this.nso.getCurrentUser(); await this.updatePresenceForSplatNet2Monitors([user.result]); } - await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000)); + await new Promise(rs => setTimeout(rs, this.update_interval * 1000)); } rpc: {client: DiscordRPC.Client, id: string} | null = null; @@ -243,7 +253,7 @@ class ZncDiscordPresence extends ZncNotifications { const show_presence = (online && 'name' in presence.game) || - (this.argv.showConsoleOnline && presence?.state === PresenceState.INACTIVE); + (this.show_console_online && presence?.state === PresenceState.INACTIVE); if (!presence || !show_presence) { if (this.rpc) { @@ -256,7 +266,7 @@ class ZncDiscordPresence extends ZncNotifications { return; } - const fc = this.argv.friendCode === '' || this.argv.friendCode === '-' ? friendcode : this.forceFriendCode; + const fc = this.show_friend_code ? this.force_friend_code ?? friendcode : undefined; const discordpresence = 'name' in presence.game ? getDiscordPresence(presence.state, presence.game, fc) : getInactiveDiscordPresence(presence.state, presence.logoutAt, fc); @@ -333,46 +343,47 @@ class ZncDiscordPresence extends ZncNotifications { } this.rpc.client.setActivity(discordpresence.activity); + + this.update_presence_errors = 0; } async update() { let user: CurrentUser | null = null; - if (this.argv.friendNaid) { + if (this.friend_nsaid) { if (!(this.nso instanceof ZncProxyApi)) await this.nso.getActiveEvent(); - const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ? - await this.nso.getFriendList() : {result: {friends: this.argv.friendNaid ? [this.argv.userNotifications ? - (await this.nso.fetch<{friend: Friend}>('/friend/' + this.argv.friendNaid)).friend : { + const friends = this.friend_notifications || !(this.nso instanceof ZncProxyApi) ? + await this.nso.getFriendList() : {result: {friends: this.friend_nsaid ? [this.user_notifications ? + (await this.nso.fetch<{friend: Friend}>('/friend/' + this.friend_nsaid)).friend : { id: 0, - nsaId: this.argv.friendNaid, + nsaId: this.friend_nsaid, imageUri: '', name: '', isFriend: true, isFavoriteFriend: false, isServiceUser: true, friendCreatedAt: 0, - presence: await this.nso.fetch('/friend/' + this.argv.friendNaid + '/presence'), + presence: await this.nso.fetch('/friend/' + this.friend_nsaid + '/presence'), }] : []}}; if (!(this.nso instanceof ZncProxyApi)) await this.nso.getWebServices(); - const friend = friends.result.friends.find(f => f.nsaId === this.argv.friendNaid); + const friend = friends.result.friends.find(f => f.nsaId === this.friend_nsaid); - if (this.argv.userNotifications && this.argv.friendNotifications) { + if (this.user_notifications && this.friend_notifications) { await this.updateFriendsStatusForNotifications(friends.result.friends); - } else if (this.argv.friendNotifications) { + } else if (this.friend_notifications) { await this.updateFriendsStatusForNotifications( - friends.result.friends.filter(f => f.nsaId !== this.argv.friendNaid)); - } else if (this.argv.userNotifications && friend) { + friends.result.friends.filter(f => f.nsaId !== this.friend_nsaid)); + } else if (this.user_notifications && friend) { await this.updateFriendsStatusForNotifications([friend]); } if (!friend) { // Is the authenticated user no longer friends with this user? await this.updatePresence(null); - return; + } else { + await this.updatePresence(friend.presence); } - - await this.updatePresence(friend.presence); } else { user = !(this.nso instanceof ZncProxyApi) ? (await this.nso.getCurrentUser()).result : { @@ -380,26 +391,46 @@ class ZncDiscordPresence extends ZncNotifications { presence: await this.nso.fetch('/user/presence'), }; - if (this.argv.friendNotifications) { + if (this.friend_notifications) { if (!(this.nso instanceof ZncProxyApi)) await this.nso.getActiveEvent(); const friends = await this.nso.getFriendList(); if (!(this.nso instanceof ZncProxyApi)) await this.nso.getWebServices(); - - await this.updateFriendsStatusForNotifications(this.argv.userNotifications ? + + await this.updateFriendsStatusForNotifications(this.user_notifications ? [user, ...friends.result.friends] : friends.result.friends); - } else if (this.argv.userNotifications) { + } else if (this.user_notifications) { await this.updateFriendsStatusForNotifications([user]); } await this.updatePresence(user.presence, user.links.friendCode); } - if (this.argv.splatnet2MonitorDirectory) { + if (this.splatnet2_monitors.size) { if (!user) user = (await this.nso.getCurrentUser()).result; await this.updatePresenceForSplatNet2Monitors([user]); } } + + update_presence_errors = 0; + + async handleError(err: ErrorResponse | NodeJS.ErrnoException): Promise { + 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); + } } class ZncProxyDiscordPresence extends ZncDiscordPresence { diff --git a/src/cli/splatnet2/monitor.ts b/src/cli/splatnet2/monitor.ts index f1ce242..cb072e5 100644 --- a/src/cli/splatnet2/monitor.ts +++ b/src/cli/splatnet2/monitor.ts @@ -5,11 +5,12 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import SplatNet2Api from '../../api/splatnet2.js'; import { getIksmToken, renewIksmToken } from './util.js'; -import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { ArgumentsCamelCase, Argv, initStorage, Loop, LoopResult, YargsArguments } from '../../util.js'; import { Records, Stages, WebServiceError } from '../../api/splatnet2-types.js'; import { Arguments as ParentArguments } from '../splatnet2.js'; import { dumpCoopResults, dumpResults } from './dump-results.js'; import { dumpProfileImage, dumpRecords } from './dump-records.js'; +import { ErrorResponse } from '../../api/util.js'; const debug = createDebug('cli:splatnet2:monitor'); @@ -107,7 +108,7 @@ export async function handler(argv: ArgumentsCamelCase) { } } -export class SplatNet2RecordsMonitor { +export class SplatNet2RecordsMonitor extends Loop { update_interval: number = 3 * 60; // 3 minutes in seconds profile_image = true; @@ -131,7 +132,9 @@ export class SplatNet2RecordsMonitor { public stages: Stages, public directory: string, public znc_proxy_url?: string - ) {} + ) { + super(); + } async init() { await mkdirp(this.directory); @@ -182,21 +185,20 @@ export class SplatNet2RecordsMonitor { } } - async loop() { - try { - await this.update(); + async handleError(err: Error | ErrorResponse): Promise { + if ('response' in err && err.data.code === 'AUTHENTICATION_ERROR') { + // Token expired + debug('Renewing iksm_session cookie'); - await new Promise(rs => setTimeout(rs, this.update_interval * 1000)); - } catch (err) { - // @ts-expect-error - if ((err?.data as WebServiceError)?.code === 'AUTHENTICATION_ERROR') { - // Token expired - debug('Renewing iksm_session cookie'); - - await renewIksmToken(this.splatnet, this.storage, this.token, this.znc_proxy_url); - } else { - throw err; + if (!this.auto_update_iksm_session) { + throw new Error('iksm_session cookie expired'); } + + await renewIksmToken(this.splatnet, this.storage, this.token, this.znc_proxy_url); + + return LoopResult.OK_SKIP_INTERVAL; + } else { + throw err; } } } diff --git a/src/util.ts b/src/util.ts index 724402b..5138061 100644 --- a/src/util.ts +++ b/src/util.ts @@ -147,3 +147,41 @@ export function hrduration(duration: number, short = false) { return minutes + ' ' + minute_str + (minutes === 1 ? '' : 's'); } } + +export abstract class Loop { + update_interval = 60; + + init(): void | Promise {} + + abstract update(): void | Promise; + + async loopRun(): Promise { + try { + await this.update(); + + return LoopResult.OK; + } catch (err) { + return this.handleError(err as any); + } + } + + async handleError(err: Error): Promise { + throw err; + } + + async loop() { + const result = await this.loopRun(); + + if (result === LoopResult.OK) { + await new Promise(rs => setTimeout(rs, this.update_interval * 1000)); + } + } +} + +const LoopRunOk = Symbol('LoopRunOk'); +const LoopRunOkSkipInterval = Symbol('LoopRunOkSkipInterval'); + +export enum LoopResult { + OK = LoopRunOk as any, + OK_SKIP_INTERVAL = LoopRunOkSkipInterval as any, +}