From b531fcced4464fad11eb63cc6e6ff6132d91b88a Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Sat, 14 May 2022 16:57:44 +0100 Subject: [PATCH] Move monitoring classes, use nxapi debug namespace for non-app/cli modules --- .gitlab-ci.yml | 6 +- src/api/f.ts | 6 +- src/api/moon.ts | 2 +- src/api/na.ts | 2 +- src/api/nooklink.ts | 2 +- src/api/splatnet2.ts | 2 +- src/api/znc-proxy.ts | 2 +- src/api/znc.ts | 2 +- src/app/main/index.ts | 16 +- src/cli/nso/http-server.ts | 12 +- src/cli/nso/notify.ts | 413 +-------------------------- src/cli/nso/presence.ts | 368 ++++-------------------- src/cli/splatnet2/dump-records.ts | 158 +--------- src/cli/splatnet2/dump-results.ts | 191 +------------ src/cli/splatnet2/monitor.ts | 110 +------ src/common/notify.ts | 405 ++++++++++++++++++++++++++ src/common/presence.ts | 285 ++++++++++++++++++ src/common/splatnet2/dump-records.ts | 158 ++++++++++ src/common/splatnet2/dump-results.ts | 192 +++++++++++++ src/common/splatnet2/monitor.ts | 109 +++++++ src/discord/titles/README.md | 21 +- src/discord/util.ts | 2 +- src/util.ts | 2 +- 23 files changed, 1261 insertions(+), 1205 deletions(-) create mode 100644 src/common/notify.ts create mode 100644 src/common/presence.ts create mode 100644 src/common/splatnet2/dump-records.ts create mode 100644 src/common/splatnet2/dump-results.ts create mode 100644 src/common/splatnet2/monitor.ts diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b937b1e..26f47b1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -24,9 +24,9 @@ build: - nxapi.tgz - app exclude: - - app/mac - - app/win-unpacked - - app/linux-unpacked + - app/mac/**/* + - app/win-unpacked/**/* + - app/linux-unpacked/**/* publish-npm: stage: deploy diff --git a/src/api/f.ts b/src/api/f.ts index 8047e61..91b2c63 100644 --- a/src/api/f.ts +++ b/src/api/f.ts @@ -3,9 +3,9 @@ import createDebug from 'debug'; import { ErrorResponse } from './util.js'; import { version } from '../util.js'; -const debugS2s = createDebug('api:s2s'); -const debugFlapg = createDebug('api:flapg'); -const debugZncaApi = createDebug('api:znca-api'); +const debugS2s = createDebug('nxapi:api:s2s'); +const debugFlapg = createDebug('nxapi:api:flapg'); +const debugZncaApi = createDebug('nxapi:api:znca-api'); export async function getLoginHash(token: string, timestamp: string | number, useragent?: string) { debugS2s('Getting login hash'); diff --git a/src/api/moon.ts b/src/api/moon.ts index 18fcf23..62a344c 100644 --- a/src/api/moon.ts +++ b/src/api/moon.ts @@ -4,7 +4,7 @@ import { getNintendoAccountToken, getNintendoAccountUser } from './na.js'; import { ErrorResponse } from './util.js'; import { DailySummaries, Devices, MonthlySummaries, MonthlySummary, MoonError, ParentalControlSettingState, SmartDevices, User } from './moon-types.js'; -const debug = createDebug('api:moon'); +const debug = createDebug('nxapi:api:moon'); const MOON_URL = 'https://api-lp1.pctl.srv.nintendo.net/moon'; export const ZNMA_CLIENT_ID = '54789befb391a838'; diff --git a/src/api/na.ts b/src/api/na.ts index 6d5b04f..5896f09 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -3,7 +3,7 @@ import createDebug from 'debug'; import { ErrorResponse } from './util.js'; import { JwtPayload } from '../util.js'; -const debug = createDebug('api:na'); +const debug = createDebug('nxapi:api:na'); export async function getNintendoAccountSessionToken(code: string, verifier: string, client_id: string) { debug('Getting Nintendo Account session token'); diff --git a/src/api/nooklink.ts b/src/api/nooklink.ts index f8232ad..70fca09 100644 --- a/src/api/nooklink.ts +++ b/src/api/nooklink.ts @@ -6,7 +6,7 @@ import { ErrorResponse } from './util.js'; import ZncApi from './znc.js'; import { WebServiceError, Users, AuthToken, UserProfile, Newspapers, Newspaper, Emoticons, Reaction, IslandProfile } from './nooklink-types.js'; -const debug = createDebug('api:nooklink'); +const debug = createDebug('nxapi:api:nooklink'); export const NOOKLINK_WEBSERVICE_ID = '4953919198265344'; export const NOOKLINK_WEBSERVICE_URL = 'https://web.sd.lp1.acbaa.srv.nintendo.net'; diff --git a/src/api/splatnet2.ts b/src/api/splatnet2.ts index a2d909f..66040fb 100644 --- a/src/api/splatnet2.ts +++ b/src/api/splatnet2.ts @@ -8,7 +8,7 @@ import ZncApi from './znc.js'; import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingSummary } from './splatnet2-types.js'; import { updateIksmSessionLastUsed } from '../cli/splatnet2/util.js'; -const debug = createDebug('api:splatnet2'); +const debug = createDebug('nxapi:api:splatnet2'); export const SPLATNET2_WEBSERVICE_ID = '5741031244955648'; export const SPLATNET2_WEBSERVICE_URL = 'https://app.splatoon2.nintendo.net/'; diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 1db7e22..4f1c482 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -7,7 +7,7 @@ import { version } from '../util.js'; import { NintendoAccountUser } from './na.js'; import { SavedToken } from '../cli/nso/util.js'; -const debug = createDebug('api:znc-proxy'); +const debug = createDebug('nxapi:api:znc-proxy'); export default class ZncProxyApi implements ZncApi { static useragent: string | null = null; diff --git a/src/api/znc.ts b/src/api/znc.ts index a432794..3b8680e 100644 --- a/src/api/znc.ts +++ b/src/api/znc.ts @@ -7,7 +7,7 @@ import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountUser } import { ErrorResponse } from './util.js'; import { JwtPayload } from '../util.js'; -const debug = createDebug('api:znc'); +const debug = createDebug('nxapi:api:znc'); const ZNCA_PLATFORM = 'Android'; const ZNCA_PLATFORM_VERSION = '8.0.0'; diff --git a/src/app/main/index.ts b/src/app/main/index.ts index 0dd3511..96d1b72 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -13,8 +13,8 @@ import { createWindow, getWindowConfiguration } from './windows.js'; import { WindowType } from '../common/types.js'; import { CurrentUser, Friend, Game, ZncErrorResponse } from '../../api/znc-types.js'; import { ErrorResponse } from '../../api/util.js'; -import { ZncDiscordPresence } from '../../cli/nso/presence.js'; -import { NotificationManager } from '../../cli/nso/notify.js'; +import { ZncDiscordPresence } from '../../common/presence.js'; +import { NotificationManager } from '../../common/notify.js'; import { getToken } from '../../cli/nso/util.js'; const debug = createDebug('app:main'); @@ -167,17 +167,7 @@ export class PresenceMonitorManager { return; } - const i = new EmbeddedPresenceMonitor({ - userNotifications: false, - friendNotifications: false, - updateInterval: 60, - friendCode: undefined, - showInactivePresence: false, - showEvent: false, - showPlayTime: 'detailed-since', - friendNsaid: undefined, - discordPreconnect: false, - }, this.store.storage, token, nso, data); + const i = new EmbeddedPresenceMonitor(this.store.storage, token, nso, data); i.notifications = this.notifications; i.presence_user = null; diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 7ab292d..f90e06a 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -8,7 +8,7 @@ import ZncApi from '../../api/znc.js'; import type { Arguments as ParentArguments } from '../nso.js'; import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; import { getToken, SavedToken } from './util.js'; -import { NotificationManager, ZncNotifications } from './notify.js'; +import { NotificationManager, ZncNotifications } from '../../common/notify.js'; declare global { namespace Express { @@ -646,11 +646,11 @@ export async function handler(argv: ArgumentsCamelCase) { res.setHeader('Content-Type', 'text/event-stream'); const nintendoAccountSessionToken = req.headers['authorization']!.substr(3); - const i = new ZncNotifications({ - ...argv, - userNotifications: false, - friendNotifications: true, - }, storage, nintendoAccountSessionToken, req.znc!, req.zncAuth!); + const i = new ZncNotifications(storage, nintendoAccountSessionToken, req.znc!, req.zncAuth!); + + i.user_notifications = false; + i.friend_notifications = true; + i.update_interval = argv.updateInterval; const es = i.notifications = new EventStreamNotificationManager(req, res); diff --git a/src/cli/nso/notify.ts b/src/cli/nso/notify.ts index 9343755..225a1bb 100644 --- a/src/cli/nso/notify.ts +++ b/src/cli/nso/notify.ts @@ -1,20 +1,15 @@ +import * as path from 'path'; import createDebug from 'debug'; import persist from 'node-persist'; import notifier from 'node-notifier'; -import * as path from 'path'; -import { ActiveEvent, Announcements, CurrentUser, Friend, Game, Presence, PresenceState, WebServices, ZncErrorResponse } from '../../api/znc-types.js'; -import ZncApi from '../../api/znc.js'; import type { Arguments as ParentArguments } from '../nso.js'; -import { ArgumentsCamelCase, Argv, getTitleIdFromEcUrl, hrduration, initStorage, Loop, LoopResult, YargsArguments } from '../../util.js'; -import { getToken, SavedToken } from './util.js'; -import ZncProxyApi from '../../api/znc-proxy.js'; -import { SplatNet2RecordsMonitor } from '../splatnet2/monitor.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getToken } from './util.js'; import { getIksmToken } from '../splatnet2/util.js'; -import { ErrorResponse } from '../../api/util.js'; +import { EmbeddedSplatNet2Monitor, NotificationManager, ZncNotifications } from '../../common/notify.js'; +import { CurrentUser, Friend, Game } from '../../api/znc-types.js'; const debug = createDebug('cli:nso:notify'); -const debugFriends = createDebug('cli:nso:notify:friends'); -const debugSplatnet2 = createDebug('cli:nso:notify:splatnet2-monitor'); export const command = 'notify'; export const desc = 'Show notifications when friends come online without starting Discord Rich Presence'; @@ -107,7 +102,11 @@ export async function handler(argv: ArgumentsCamelCase) { await storage.getItem('NintendoAccountToken.' + usernsid); const {nso, data} = await getToken(storage, token, argv.zncProxyUrl); - const i = new ZncNotifications(argv, storage, token, nso, data); + const i = new ZncNotifications(storage, token, nso, data); + + i.user_notifications = argv.userNotifications; + i.friend_notifications = argv.friendNotifications; + i.update_interval = argv.updateInterval; console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)', data.user.screenName, data.user.nickname, data.nsoAccount.user.name); @@ -126,197 +125,7 @@ export async function handler(argv: ArgumentsCamelCase) { } } -export class ZncNotifications extends Loop { - notifications = new NotificationManager(); - splatnet2_monitors = new Map Promise)>(); - - user_notifications = true; - friend_notifications = true; - update_interval = 30; - - constructor( - 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 fetch(req: ( - 'announcements' | 'friends' | {friend: string; presence?: boolean} | 'webservices' | 'event' | 'user' | null - )[]) { - const result: Partial<{ - announcements: Announcements; - friends: Friend[]; - webservices: WebServices; - activeevent: ActiveEvent; - user: CurrentUser; - }> = {}; - - const friends = req.filter(r => typeof r === 'object' && r && 'friend' in r) as - {friend: string; presence?: boolean}[]; - - if (!(this.nso instanceof ZncProxyApi)) { - if (req.includes('announcements')) req.push('friends', 'webservices', 'event'); - if (req.includes('webservices')) req.push('friends', 'event'); - if (req.includes('event')) req.push('friends', 'webservices'); - } - - if (req.includes('announcements')) { - result.announcements = (await this.nso.getAnnouncements()).result; - } - if (req.includes('friends') || (friends && !(this.nso instanceof ZncProxyApi))) { - result.friends = (await this.nso.getFriendList()).result.friends; - } else if (friends && this.nso instanceof ZncProxyApi) { - result.friends = await Promise.all(friends.map(async r => { - const nso = this.nso as unknown as ZncProxyApi; - - if (r.presence) { - const friend: Friend = { - id: 0, - nsaId: r.friend, - imageUri: '', - name: '', - isFriend: true, - isFavoriteFriend: false, - isServiceUser: false, - friendCreatedAt: 0, - presence: await nso.fetch('/friend/' + r.friend + '/presence'), - }; - - return friend; - } - - return (await nso.fetch<{friend: Friend}>('/friend/' + r.friend)).friend; - })) - } - if (req.includes('webservices')) { - result.webservices = (await this.nso.getWebServices()).result; - } - if (req.includes('event')) { - const activeevent = (await this.nso.getActiveEvent()).result; - result.activeevent = 'id' in activeevent ? activeevent : undefined; - } - if (req.includes('user')) { - result.user = (await this.nso.getCurrentUser()).result; - } - - return result; - } - - async init() { - const {friends, user} = await this.fetch([ - 'announcements', - this.user_notifications ? 'user' : null, - this.friend_notifications ? 'friends' : null, - this.splatnet2_monitors.size ? 'user' : null, - ]); - - await this.updatePresenceForNotifications(user, friends, this.data.user.id, true); - if (user) await this.updatePresenceForSplatNet2Monitors([user]); - - return LoopResult.OK; - } - - async updateFriendsStatusForNotifications( - friends: (CurrentUser | Friend)[], - naid = this.data.user.id, - initialRun?: boolean - ) { - this.notifications.updateFriendsStatusForNotifications(friends, naid, initialRun); - } - - async updatePresenceForNotifications( - user: CurrentUser | undefined, friends: Friend[] | undefined, - naid = this.data.user.id, initialRun?: boolean - ) { - await this.updateFriendsStatusForNotifications(([] as (CurrentUser | Friend)[]) - .concat(this.user_notifications && user ? [user] : []) - .concat(this.friend_notifications && friends ? friends : []), naid, initialRun); - } - - async updatePresenceForSplatNet2Monitors(friends: (CurrentUser | Friend)[]) { - for (const friend of friends) { - await this.updatePresenceForSplatNet2Monitor(friend.presence, friend.nsaId, friend.name); - } - } - - async updatePresenceForSplatNet2Monitor(presence: Presence, nsa_id: string, name?: string) { - const playing = presence.state === PresenceState.PLAYING; - const monitor = this.splatnet2_monitors.get(nsa_id); - - if (playing && monitor) { - const currenttitle = presence.game as Game; - const titleid = getTitleIdFromEcUrl(currenttitle.shopUri); - - if (titleid && EmbeddedSplatNet2Monitor.title_ids.includes(titleid)) { - if ('enable' in monitor) { - monitor.enable(); - if (!monitor.enabled) debugSplatnet2('Started monitor for user %s', name ?? nsa_id); - } else { - const m = await monitor.call(null); - this.splatnet2_monitors.set(nsa_id, m); - m.enable(); - debugSplatnet2('Started monitor for user %s', name ?? nsa_id); - } - } else if ('disable' in monitor) { - if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id); - monitor.disable(); - } - } else if (monitor && 'disable' in monitor) { - if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id); - monitor.disable(); - } - } - - async update() { - const {friends, user} = await this.fetch([ - this.user_notifications ? 'user' : null, - this.friend_notifications ? 'friends' : null, - this.splatnet2_monitors.size ? 'user' : null, - ]); - - await this.updatePresenceForNotifications(user, friends); - if (user) await this.updatePresenceForSplatNet2Monitors([user]); - } - - async handleError(err: ErrorResponse | NodeJS.ErrnoException): Promise { - if (err && 'response' in err && err.data?.status === 9404) { - // Token expired - debug('Renewing token'); - - const data = await this.nso.renewToken(this.token, this.data.user); - - const existingToken: SavedToken = { - user: this.data.user, - ...data, - expires_at: Date.now() + (data.credential.expiresIn * 1000), - }; - - await this.storage.setItem('NsoToken.' + this.token, existingToken); - - 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; - } - } -} - -export class NotificationManager { +export class TerminalNotificationManager extends NotificationManager { onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) { const currenttitle = friend.presence.game as Game; @@ -360,206 +169,6 @@ export class NotificationManager { icon: friend.imageUri, }); } - - onlinefriends = new Map(); - accounts = new Map(); - - updateFriendsStatusForNotifications(friends: (CurrentUser | Friend)[], naid: string, initialRun?: boolean) { - const newonlinefriends: (CurrentUser | Friend)[] = []; - - for (const friend of friends) { - const prev = this.onlinefriends.get(naid)?.find(f => f.nsaId === friend.nsaId); - const lastpresence = prev?.presence; - const online = friend.presence.state === PresenceState.ONLINE || - friend.presence.state === PresenceState.PLAYING; - const wasonline = lastpresence?.state === PresenceState.ONLINE || - lastpresence?.state === PresenceState.PLAYING; - const consolewasonline = wasonline || - lastpresence?.state === PresenceState.INACTIVE; - - if (friend.presence.state !== PresenceState.OFFLINE || friend.presence.updatedAt) { - if (!this.accounts.has(friend.nsaId)) { - this.accounts.set(friend.nsaId, naid); - } - } - - newonlinefriends.push(friend); - - // Another account is monitoring this user's presence - if (this.accounts.get(friend.nsaId) !== naid) continue; - - if (!wasonline && online) { - // Friend has come online - const currenttitle = friend.presence.game as Game; - - debugFriends('%s is now online%s%s, title %s %s - played for %s since %s', friend.name, - friend.presence.state === PresenceState.ONLINE ? '' : ' (' + friend.presence.state + ')', - consolewasonline ? ' (console was already online)' : '', - currenttitle.name, JSON.stringify(currenttitle.sysDescription), - hrduration(currenttitle.totalPlayTime), - currenttitle.firstPlayedAt ? new Date(currenttitle.firstPlayedAt * 1000).toString() : 'now'); - - this.onFriendOnline(friend, prev, naid, initialRun); - - if (consolewasonline) { - // Friend's console was already online - } - } else if (wasonline && !online) { - // Friend has gone offline - const lasttitle = lastpresence.game as Game; - - debugFriends('%s is now offline%s, was playing title %s %s', friend.name, - friend.presence.state !== PresenceState.OFFLINE ? ' (console still online)' : '', - lasttitle.name, JSON.stringify(lasttitle.sysDescription)); - - this.onFriendOffline(friend, prev, naid, initialRun); - - if (friend.presence.state !== PresenceState.OFFLINE) { - // Friend's console is still online - } - } else if (wasonline && online) { - // Friend is still online - const lasttitle = lastpresence.game as Game; - const currenttitle = friend.presence.game as Game; - - if (getTitleIdFromEcUrl(lasttitle.shopUri) !== getTitleIdFromEcUrl(currenttitle.shopUri)) { - // Friend is playing a different title - - debugFriends('%s title is now %s %s%s, was playing %s %s%s - played for %s since %s', - friend.name, - currenttitle.name, JSON.stringify(currenttitle.sysDescription), - friend.presence.state === PresenceState.ONLINE ? '' : ' (' + friend.presence.state + ')', - lasttitle.name, JSON.stringify(lasttitle.sysDescription), - lastpresence.state === PresenceState.ONLINE ? '' : ' (' + lastpresence.state + ')', - hrduration(currenttitle.totalPlayTime), - currenttitle.firstPlayedAt ? new Date(currenttitle.firstPlayedAt * 1000).toString() : 'now'); - - this.onFriendPlayingChangeTitle(friend, prev, naid, initialRun); - } else if ( - lastpresence.state !== friend.presence.state || - lasttitle.sysDescription !== currenttitle.sysDescription - ) { - // Title state changed - - debugFriends('%s title %s state changed, now %s %s, was %s %s', - friend.name, currenttitle.name, - friend.presence.state, JSON.stringify(currenttitle.sysDescription), - lastpresence.state, JSON.stringify(lasttitle.sysDescription)); - - this.onFriendTitleStateChange(friend, prev, naid, initialRun); - } else if ( - lastpresence.state !== friend.presence.state || - lasttitle.sysDescription !== currenttitle.sysDescription - ) { - // Presence state changed (between online/playing) - - debugFriends('%s title %s state changed%s, now %s %s, was %s %s', - friend.name, currenttitle.name, - friend.presence.state, JSON.stringify(currenttitle.sysDescription), - lastpresence.state, JSON.stringify(lasttitle.sysDescription)); - } - } else if (!consolewasonline && friend.presence.state !== PresenceState.OFFLINE) { - // Friend's console is now online, but the user is not playing - - debugFriends('%s\'s console is now online', friend.name); - } else if (consolewasonline && friend.presence.state !== PresenceState.OFFLINE) { - // Friend's console is still online, the user is still not playing - } else if (consolewasonline && friend.presence.state === PresenceState.OFFLINE) { - // Friend's console is now offline - - debugFriends('%s\'s console is now offline', friend.name); - } - } - - for (const friend of this.onlinefriends.get(naid) ?? []) { - const updated = newonlinefriends.find(f => f.nsaId === friend.nsaId); - - if (!updated) { - // The authenticated user is no longer friends with this user, or received an empty presence - // object (no longer has permission to view the user's status?) - - if (this.accounts.get(friend.nsaId) !== naid) continue; - this.accounts.delete(friend.nsaId); - - for (const [naid, onlinefriends] of this.onlinefriends) { - if (onlinefriends.find(f => f.nsaId === friend.nsaId && - (f.presence.state !== PresenceState.OFFLINE || f.presence.updatedAt) - )) { - this.accounts.set(friend.nsaId, naid); - break; - } - } - } - } - - this.onlinefriends.set(naid, newonlinefriends); - } - - removeAccount(naid: string) { - const onlinefriends = this.onlinefriends.get(naid); - if (!onlinefriends) return; - - this.onlinefriends.delete(naid); - - for (const friend of onlinefriends) { - if (this.accounts.get(friend.nsaId) !== naid) continue; - this.accounts.delete(friend.nsaId); - - for (const [naid, onlinefriends] of this.onlinefriends) { - if (onlinefriends.find(f => f.nsaId === friend.nsaId && - (f.presence.state !== PresenceState.OFFLINE || f.presence.updatedAt) - )) { - this.accounts.set(friend.nsaId, naid); - break; - } - } - } - } -} - -export class EmbeddedSplatNet2Monitor extends SplatNet2RecordsMonitor { - static title_ids = [ - '0100f8f0000a2000', // Europe - '01003bc0000a0000', // The Americas - '01003c700009c000', // Japan - ]; - - enable() { - if (this._running !== 0) return; - this._run(); - } - - disable() { - this._running = 0; - } - - get enabled() { - return this._running !== 0; - } - - private _running = 0; - - private async _run() { - this._running++; - const i = this._running; - - try { - await this.loop(true); - - while (i === this._running) { - 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; - } - } } export function handleEnableSplatNet2Monitoring( diff --git a/src/cli/nso/presence.ts b/src/cli/nso/presence.ts index c19be53..5f0abc9 100644 --- a/src/cli/nso/presence.ts +++ b/src/cli/nso/presence.ts @@ -1,15 +1,10 @@ import createDebug from 'debug'; -import persist from 'node-persist'; -import DiscordRPC from 'discord-rpc'; -import { ActiveEvent, CurrentUser, Friend, 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, initStorage, LoopResult, YargsArguments } from '../../util.js'; -import { getToken, SavedToken } from './util.js'; -import { DiscordPresencePlayTime, DiscordPresenceContext, getDiscordPresence, getInactiveDiscordPresence } from '../../discord/util.js'; -import { handleEnableSplatNet2Monitoring, ZncNotifications } from './notify.js'; -import { ErrorResponse } from '../../index.js'; -import { getPresenceFromUrl } from '../../api/znc-proxy.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getToken } from './util.js'; +import { DiscordPresencePlayTime } from '../../discord/util.js'; +import { handleEnableSplatNet2Monitoring } from './notify.js'; +import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js'; const debug = createDebug('cli:nso:presence'); const debugProxy = createDebug('cli:nso:presence:proxy'); @@ -127,7 +122,26 @@ export async function handler(argv: ArgumentsCamelCase) { if (argv.userNotifications) throw new Error('--presence-url not compatible with --user-notifications'); if (argv.friendNotifications) throw new Error('--presence-url not compatible with --friend-notifications'); - const i = new ZncProxyDiscordPresence(argv, argv.presenceUrl); + const i = new ZncProxyDiscordPresence(argv.presenceUrl); + + i.update_interval = argv.updateInterval; + + let match; + i.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; + i.show_friend_code = !!i.force_friend_code || argv.friendCode === '' || argv.friendCode === '-'; + i.show_console_online = argv.showInactivePresence; + + i.show_play_time = argv.showPlayTime.toLowerCase() === 'hidden' ? DiscordPresencePlayTime.HIDDEN : + argv.showPlayTime.toLowerCase() === 'nintendo' ? DiscordPresencePlayTime.NINTENDO : + argv.showPlayTime.toLowerCase() === 'approximate' ? DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME : + argv.showPlayTime.toLowerCase() === 'approximate-since' ? DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE : + argv.showPlayTime.toLowerCase() === 'detailed' ? DiscordPresencePlayTime.DETAILED_PLAY_TIME : + argv.showPlayTime.toLowerCase() === 'detailed-since' ? DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE : + DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE; + + i.discord_preconnect = argv.discordPreconnect; if (argv.splatnet2Monitor) { const storage = await initStorage(argv.dataPath); @@ -170,7 +184,30 @@ export async function handler(argv: ArgumentsCamelCase) { await storage.getItem('NintendoAccountToken.' + usernsid); const {nso, data} = await getToken(storage, token, argv.zncProxyUrl); - const i = new ZncDiscordPresence(argv, storage, token, nso, data); + const i = new ZncDiscordPresence(storage, token, nso, data); + + i.user_notifications = argv.userNotifications; + i.friend_notifications = argv.friendNotifications; + i.update_interval = argv.updateInterval; + + let match; + i.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; + i.show_friend_code = !!i.force_friend_code || argv.friendCode === '' || argv.friendCode === '-'; + i.show_console_online = argv.showInactivePresence; + i.show_active_event = argv.showEvent; + + i.show_play_time = argv.showPlayTime.toLowerCase() === 'hidden' ? DiscordPresencePlayTime.HIDDEN : + argv.showPlayTime.toLowerCase() === 'nintendo' ? DiscordPresencePlayTime.NINTENDO : + argv.showPlayTime.toLowerCase() === 'approximate' ? DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME : + argv.showPlayTime.toLowerCase() === 'approximate-since' ? DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE : + argv.showPlayTime.toLowerCase() === 'detailed' ? DiscordPresencePlayTime.DETAILED_PLAY_TIME : + argv.showPlayTime.toLowerCase() === 'detailed-since' ? DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE : + DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE; + + i.presence_user = argv.friendNsaid ?? data?.nsoAccount.user.nsaId; + i.discord_preconnect = argv.discordPreconnect; console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)', data.user.screenName, data.user.nickname, data.nsoAccount.user.name); @@ -189,310 +226,3 @@ export async function handler(argv: ArgumentsCamelCase) { await i.loop(); } } - -export class ZncDiscordPresence extends ZncNotifications { - presence_user: string | null; - discord_preconnect = false; - show_friend_code = false; - force_friend_code: CurrentUser['links']['friendCode'] | undefined = undefined; - show_console_online = false; - show_active_event = true; - show_play_time = DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE; - - constructor( - argv: Pick, - 'userNotifications' | 'friendNotifications' | 'updateInterval' | - 'friendCode' | 'showInactivePresence' | 'showEvent' | 'friendNsaid' | - 'showPlayTime' | 'discordPreconnect' - >, - storage: persist.LocalStorage, - token: string, - nso: ZncApi, - data: Omit, - ) { - 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.show_play_time = argv.showPlayTime.toLowerCase() === 'hidden' ? DiscordPresencePlayTime.HIDDEN : - argv.showPlayTime.toLowerCase() === 'nintendo' ? DiscordPresencePlayTime.NINTENDO : - argv.showPlayTime.toLowerCase() === 'approximate' ? DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME : - argv.showPlayTime.toLowerCase() === 'approximate-since' ? DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE : - argv.showPlayTime.toLowerCase() === 'detailed' ? DiscordPresencePlayTime.DETAILED_PLAY_TIME : - argv.showPlayTime.toLowerCase() === 'detailed-since' ? DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE : - DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE; - - this.presence_user = argv.friendNsaid ?? data?.nsoAccount.user.nsaId; - this.discord_preconnect = argv.discordPreconnect; - } - - 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} : 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, friend); - } else { - await this.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent); - } - } - - await this.updatePresenceForNotifications(user, friends); - if (user) await this.updatePresenceForSplatNet2Monitors([user]); - - return LoopResult.OK; - } - - get presence_enabled() { - return !!this.presence_user; - } - - rpc: {client: DiscordRPC.Client, id: string} | null = null; - title: {id: string; since: number} | null = null; - i = 0; - - last_presence: Presence | null = null; - last_user: CurrentUser | Friend | undefined = undefined; - last_friendcode: CurrentUser['links']['friendCode'] | undefined = undefined; - last_event: ActiveEvent | undefined = undefined; - - async updatePresenceForDiscord( - presence: Presence | null, - user?: CurrentUser | Friend, - friendcode?: CurrentUser['links']['friendCode'], - activeevent?: ActiveEvent - ) { - this.last_presence = presence; - this.last_user = user; - this.last_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.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); - this.rpc = {client, id: discordpresence.id}; - return; - } - - if (this.rpc && !(this.presence_enabled && this.discord_preconnect)) { - const client = this.rpc.client; - this.rpc = null; - await client.destroy(); - } else if (this.rpc && this.title) { - debugDiscord('No presence but Discord preconnect enabled - clearing Discord activity'); - await this.rpc.client.clearActivity(); - } - - this.title = null; - return; - } - - const presencecontext: DiscordPresenceContext = { - friendcode: this.show_friend_code ? this.force_friend_code ?? friendcode : undefined, - activeevent: this.show_active_event ? activeevent : undefined, - show_play_time: this.show_play_time, - znc_discord_presence: this, - nsaid: this.presence_user!, - 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 = await this.createDiscordClient(discordpresence.id); - 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 createDiscordClient(clientid: string) { - let client: DiscordRPC.Client; - let attempts = 0; - let connected = false; - - 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); - connected = true; - break; - } catch (err) {} - - attempts++; - await new Promise(rs => setTimeout(rs, 5000)); - } - - if (!connected) throw new Error('Failed to connect to Discord'); - - const reconnect = async () => { - if (this.rpc?.client !== client) return; - - debugDiscord('RPC client disconnected, attempting to reconnect', clientid); - let attempts = 0; - let connected = false; - - while (attempts < 10) { - if (this.rpc?.client !== client) return; - - 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); - - // @ts-expect-error - client.transport.on('close', reconnect); - - this.rpc.client = newclient; - client = newclient; - connected = true; - break; - } catch (err) { - debugDiscord('RPC reconnect failed, attempt %d', attempts + 1, clientid, err); - } - - attempts++; - await new Promise(rs => setTimeout(rs, 5000)); - } - - if (!connected) throw new Error('Failed to reconnect to Discord'); - }; - - // @ts-expect-error - client.transport.on('close', reconnect); - - return client!; - } - - 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} : 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, friend); - } - } else { - await this.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent); - } - } - - await this.updatePresenceForNotifications(user, friends); - if (user) 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); - } -} - -export class ZncProxyDiscordPresence extends ZncDiscordPresence { - constructor( - readonly argv: ArgumentsCamelCase, - public presence_url: string - ) { - super(argv, null!, null!, null!, null!); - } - - get presence_enabled() { - return true; - } - - async init() { - await this.update(); - - return LoopResult.OK; - } - - async update() { - const [presence, user] = await getPresenceFromUrl(this.presence_url); - - await this.updatePresenceForDiscord(presence, user); - await this.updatePresenceForSplatNet2Monitor(presence, this.presence_url); - } -} diff --git a/src/cli/splatnet2/dump-records.ts b/src/cli/splatnet2/dump-records.ts index 17005ca..7b3e41a 100644 --- a/src/cli/splatnet2/dump-records.ts +++ b/src/cli/splatnet2/dump-records.ts @@ -1,14 +1,11 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; import createDebug from 'debug'; import mkdirp from 'mkdirp'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import fetch from 'node-fetch'; -import * as crypto from 'crypto'; import type { Arguments as ParentArguments } from '../splatnet2.js'; import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; import { getIksmToken } from './util.js'; -import SplatNet2Api, { ShareColour } from '../../api/splatnet2.js'; -import { Challenge, NicknameAndIcon, Records, Stages } from '../../api/splatnet2-types.js'; +import { dumpChallenges, dumpHeroRecords, dumpProfileImage, dumpRecords } from '../../common/splatnet2/dump-records.js'; const debug = createDebug('cli:splatnet2:dump-records'); @@ -112,152 +109,3 @@ export async function handler(argv: ArgumentsCamelCase) { await fs.writeFile(file, JSON.stringify(timeline, null, 4) + '\n', 'utf-8'); } } - -export async function dumpRecords( - directory: string, user_id: string, - records: Records, nickname_and_icon: NicknameAndIcon, - updated?: Date -) { - const latest_filename = 'splatnet2-records-' + user_id + '-latest.json'; - const latest_file = path.join(directory, latest_filename); - - if (updated) { - try { - const {sn2_updated_timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); - - if ((sn2_updated_timestamp * 1000) >= updated.getTime()) { - debug('Skipping user records, not updated'); - return; - } - } catch (err) {} - } - - const timestamp = Date.now(); - const sn2_updated_timestamp = records.records.update_time; - - const filename = 'splatnet2-records-' + user_id + '-' + timestamp + '.json'; - const file = path.join(directory, filename); - const ni_filename = 'splatnet2-ni-' + user_id + '-' + timestamp + '.json'; - const ni_file = path.join(directory, ni_filename); - - debug('Writing records %s', filename); - await fs.writeFile(file, JSON.stringify(records, null, 4) + '\n', 'utf-8'); - - debug('Writing records %s', ni_filename); - await fs.writeFile(ni_file, JSON.stringify(nickname_and_icon, null, 4) + '\n', 'utf-8'); - - await fs.writeFile(latest_file, JSON.stringify({timestamp, sn2_updated_timestamp}, null, 4) + '\n', 'utf-8'); -} - -export async function dumpProfileImage( - splatnet: SplatNet2Api, directory: string, user_id: string, - stages: Stages, nickname_and_icon: NicknameAndIcon, - favourite_stage?: Arguments['favourite-stage'], favourite_colour?: Arguments['favourite-colour'], - updated?: Date -) { - const stage = favourite_stage ? - stages.stages.find(s => s.id === favourite_stage || - s.name.toLowerCase() === favourite_stage?.toLowerCase()) : - stages.stages[0]; - - if (!stage) { - debug('Invalid favourite stage "%s"', favourite_stage); - } - - // @ts-expect-error - if (!Object.values(ShareColour).includes(favourite_colour)) { - favourite_colour = ShareColour.PINK; - } - - const latest_filename = 'splatnet2-profile-' + user_id + '-latest.json'; - const latest_file = path.join(directory, latest_filename); - - const etag_data = { - stage: (stage ?? stages.stages[0]).id, - stage_image: (stage ?? stages.stages[0]).image, - colour: favourite_colour, - - name: nickname_and_icon.nickname, - image_url: nickname_and_icon.thumbnail_url, - }; - const etag = crypto.createHash('sha256').update(JSON.stringify(etag_data)).digest('base64'); - - if (updated) { - try { - const {timestamp, etag: prev_etag} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); - - if (timestamp > updated.getTime() && etag === prev_etag) { - debug('Skipping profile image, not updated'); - return; - } - } catch (err) {} - } - - const timestamp = Date.now(); - const filename = 'splatnet2-profile-' + user_id + '-' + timestamp + '.json'; - const file = path.join(directory, filename); - const image_filename = 'splatnet2-profile-' + user_id + '-' + timestamp + '.png'; - const image_file = path.join(directory, image_filename); - - debug('Fetching profile image URL'); - const share = await splatnet.shareProfile(stage?.id ?? stages.stages[0].id, favourite_colour as ShareColour); - - debug('Fetching profile image'); - const image_response = await fetch(share.url); - const image = await image_response.buffer(); - - debug('Writing profile image data %s', filename); - await fs.writeFile(file, JSON.stringify({ - share, - stage: stage ?? stages.stages[0], - colour: favourite_colour, - }, null, 4) + '\n', 'utf-8'); - - debug('Writing profile image %s', image_filename); - await fs.writeFile(image_file, image); - - await fs.writeFile(latest_file, JSON.stringify({timestamp, etag, etag_data}, null, 4) + '\n', 'utf-8'); -} - -async function dumpChallenges( - splatnet: SplatNet2Api, directory: string, user_id: string, - challenges: Challenge[], season: 1 | 2 -) { - for (const challenge of challenges) { - const filename = 'splatnet2-challenge-' + user_id + '-' + challenge.key + '.json'; - const file = path.join(directory, filename); - const image_filename = 'splatnet2-challenge-' + user_id + '-' + challenge.key + '.png'; - const image_file = path.join(directory, image_filename); - - try { - await fs.stat(file); - await fs.stat(image_file); - debug('Skipping challenge image %s, file already exists', challenge.key); - continue; - } catch (err) {} - - debug('Fetching challenge image URL for %s', challenge.key); - const share = await splatnet.shareChallenge(challenge.key, season); - - debug('Fetching challenge image for %s', challenge.key); - const image_response = await fetch(share.url); - const image = await image_response.buffer(); - - debug('Writing challenge image data %s', filename); - await fs.writeFile(file, JSON.stringify({share}, null, 4) + '\n', 'utf-8'); - - debug('Writing challenge image %s', filename); - await fs.writeFile(image_file, image); - } -} - -async function dumpHeroRecords(splatnet: SplatNet2Api, directory: string, user_id: string) { - debug('Fetching hero records'); - const hero = await splatnet.getHeroRecords(); - - const filename = 'splatnet2-hero-' + user_id + '-' + Date.now() + '.json'; - const file = path.join(directory, filename); - - debug('Writing hero records %s', filename); - await fs.writeFile(file, JSON.stringify(hero, null, 4) + '\n', 'utf-8'); -} diff --git a/src/cli/splatnet2/dump-results.ts b/src/cli/splatnet2/dump-results.ts index 76771a2..bc83fa9 100644 --- a/src/cli/splatnet2/dump-results.ts +++ b/src/cli/splatnet2/dump-results.ts @@ -1,13 +1,10 @@ +import * as path from 'path'; import createDebug from 'debug'; import mkdirp from 'mkdirp'; -import * as fs from 'fs/promises'; -import * as path from 'path'; import type { Arguments as ParentArguments } from '../splatnet2.js'; import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; import { getIksmToken } from './util.js'; -import SplatNet2Api from '../../api/splatnet2.js'; -import { NicknameAndIcon } from '../../api/splatnet2-types.js'; -import fetch from 'node-fetch'; +import { dumpCoopResults, dumpResults } from '../../common/splatnet2/dump-results.js'; const debug = createDebug('cli:splatnet2:dump-results'); @@ -73,187 +70,3 @@ export async function handler(argv: ArgumentsCamelCase) { await dumpCoopResults(splatnet, directory, records.records.unique_id, updated); } } - -export async function dumpResults( - splatnet: SplatNet2Api, directory: string, user_id: string, - images = false, summary_image = images, updated?: Date -) { - const latest_filename = 'splatnet2-results-summary-' + user_id + '-latest.json'; - const latest_file = path.join(directory, latest_filename); - - if (updated) { - try { - const {timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); - - if (timestamp > updated.getTime()) { - debug('Skipping battle results, user records not updated'); - return; - } - } catch (err) {} - } - - debug('Fetching battle results'); - const results = await splatnet.getResults(); - - const timestamp = Date.now(); - const summary_filename = 'splatnet2-results-summary-' + results.unique_id + '-' + timestamp + '.json'; - const summary_file = path.join(directory, summary_filename); - - debug('Writing summary %s', summary_filename); - await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8'); - - if (summary_image) { - const filename = 'splatnet2-results-summary-image-' + results.unique_id + '-' + timestamp + '.json'; - const file = path.join(directory, filename); - const image_filename = 'splatnet2-results-summary-image-' + results.unique_id + '-' + timestamp + '.png'; - const image_file = path.join(directory, image_filename); - - debug('Fetching battle results summary image URL'); - const share = await splatnet.shareResultsSummary(); - - debug('Fetching battle results summary image'); - const image_response = await fetch(share.url); - const image = await image_response.buffer(); - - debug('Writing battle results summary image data %s', filename); - await fs.writeFile(file, JSON.stringify({ - share, - }, null, 4) + '\n', 'utf-8'); - - debug('Writing battle results summary image %s', filename); - await fs.writeFile(image_file, image); - } - - const skipped = []; - const skipped_images = []; - - for (const item of results.results) { - const filename = 'splatnet2-result-' + results.unique_id + '-' + item.battle_number + '-' + item.type + '.json'; - const file = path.join(directory, filename); - - try { - await fs.stat(file); - skipped.push(item.battle_number); - } catch (err) { - debug('Fetching battle result %d', item.battle_number); - const result = await splatnet.getResult(item.battle_number); - - const nickname_and_icons: NicknameAndIcon[] = []; - - for (const playerresult of [result.player_result, ...result.my_team_members, ...result.other_team_members]) { - const nickname_and_icon = await splatnet.getUserNicknameAndIcon([playerresult.player.principal_id]); - nickname_and_icons.push(...nickname_and_icon.nickname_and_icons); - } - - debug('Writing %s', filename); - await fs.writeFile(file, JSON.stringify({ - result, - nickname_and_icons, - }, null, 4) + '\n', 'utf-8'); - } - - if (images) { - const filename = 'splatnet2-result-image-' + results.unique_id + '-' + - item.battle_number + '-' + item.type + '.json'; - const file = path.join(directory, filename); - const image_filename = 'splatnet2-result-image-' + results.unique_id + '-' + - item.battle_number + '-' + item.type + '.png'; - const image_file = path.join(directory, image_filename); - - try { - await fs.stat(file); - await fs.stat(image_file); - skipped_images.push(item.battle_number); - } catch (err) { - debug('Fetching battle results image URL'); - const share = await splatnet.shareResult(item.battle_number); - - debug('Fetching battle results image'); - const image_response = await fetch(share.url); - const image = await image_response.buffer(); - - debug('Writing battle results image data %s', filename); - await fs.writeFile(file, JSON.stringify({ - share, - }, null, 4) + '\n', 'utf-8'); - - debug('Writing battle results image %s', filename); - await fs.writeFile(image_file, image); - } - } - } - - if (skipped.length) { - if (skipped.length === 1) debug('Skipped battle result %d, file already exists', skipped[0]); - else debug('Skipped battle results %s, files already exist', skipped.join(', ')); - } - if (skipped_images.length) { - if (skipped_images.length === 1) debug('Skipped battle result image %d, file already exists', - skipped_images[0]); - else debug('Skipped battle result images %s, files already exist', skipped_images.join(', ')); - } - - await fs.writeFile(latest_file, JSON.stringify({timestamp}, null, 4) + '\n', 'utf-8'); -} - -export async function dumpCoopResults(splatnet: SplatNet2Api, directory: string, user_id: string, updated?: Date) { - const latest_filename = 'splatnet2-coop-summary-' + user_id + '-latest.json'; - const latest_file = path.join(directory, latest_filename); - - if (updated) { - try { - const {timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); - - if (timestamp > updated.getTime()) { - debug('Skipping coop results, user records not updated'); - return; - } - } catch (err) {} - } - - debug('Fetching coop results'); - const results = await splatnet.getCoopResults(); - - const timestamp = Date.now(); - const summary_filename = 'splatnet2-coop-summary-' + user_id + '-' + Date.now() + '.json'; - const summary_file = path.join(directory, summary_filename); - - debug('Writing summary %s', summary_filename); - await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8'); - - const skipped = []; - - for (const item of results.results) { - const filename = 'splatnet2-coop-result-' + user_id + '-' + item.job_id + '.json'; - const file = path.join(directory, filename); - - try { - await fs.stat(file); - skipped.push(item.job_id); - continue; - } catch (err) {} - - debug('Fetching coop result %d', item.job_id); - const result = await splatnet.getCoopResult(item.job_id); - - const nickname_and_icons: NicknameAndIcon[] = []; - - for (const playerresult of [result.my_result, ...result.other_results]) { - const nickname_and_icon = await splatnet.getUserNicknameAndIcon([playerresult.pid]); - nickname_and_icons.push(...nickname_and_icon.nickname_and_icons); - } - - debug('Writing %s', filename); - await fs.writeFile(file, JSON.stringify({ - result, - nickname_and_icons, - }, null, 4) + '\n', 'utf-8'); - } - - if (skipped.length) { - if (skipped.length === 1) debug('Skipped coop result %d, file already exists', skipped[0]); - else debug('Skipped coop results %s, files already exist', skipped.join(', ')); - } - - await fs.writeFile(latest_file, JSON.stringify({timestamp}, null, 4) + '\n', 'utf-8'); -} diff --git a/src/cli/splatnet2/monitor.ts b/src/cli/splatnet2/monitor.ts index 6a80bc8..4c1594b 100644 --- a/src/cli/splatnet2/monitor.ts +++ b/src/cli/splatnet2/monitor.ts @@ -1,16 +1,9 @@ -import createDebug from 'debug'; -import persist from 'node-persist'; -import mkdirp from 'mkdirp'; -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, Loop, LoopResult, YargsArguments } from '../../util.js'; -import { Records, Stages, WebServiceError } from '../../api/splatnet2-types.js'; +import createDebug from 'debug'; +import { getIksmToken } from './util.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.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'; +import { SplatNet2RecordsMonitor } from '../../common/splatnet2/monitor.js'; const debug = createDebug('cli:splatnet2:monitor'); @@ -107,98 +100,3 @@ export async function handler(argv: ArgumentsCamelCase) { await i.loop(); } } - -export class SplatNet2RecordsMonitor extends Loop { - update_interval: number = 3 * 60; // 3 minutes in seconds - - profile_image = true; - favourite_stage: string | undefined = undefined; - favourite_colour: string | undefined = undefined; - - results = true; - results_summary_image = true; - result_images = true; - coop_results = true; - - /** Prevents redownloading user records on the first loop run */ - cached_records: Records | null = null; - - auto_update_iksm_session = true; - - constructor( - public storage: persist.LocalStorage, - public token: string, - public splatnet: SplatNet2Api, - public stages: Stages, - public directory: string, - public znc_proxy_url?: string - ) { - super(); - } - - async init() { - await mkdirp(this.directory); - } - - async hasChanged(records: Records) { - const latest_filename = 'splatnet2-records-' + records.records.unique_id + '-latest.json'; - const latest_file = path.join(this.directory, latest_filename); - - const updated = new Date(records.records.update_time * 1000); - - try { - const {sn2_updated_timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); - - return (sn2_updated_timestamp * 1000) < updated.getTime(); - } catch (err) {} - - return true; - } - - async update() { - const records = this.cached_records ?? await this.splatnet.getRecords(); - this.cached_records = null; - - if (!(await this.hasChanged(records))) { - debug('No data changed'); - return; - } - - const nickname_and_icons = await this.splatnet.getUserNicknameAndIcon([records.records.player.principal_id]); - - // This will also update the splatnet2-records-...-latest.json file - await dumpRecords(this.directory, records.records.unique_id, records, - nickname_and_icons.nickname_and_icons[0]); - - if (this.profile_image) { - await dumpProfileImage(this.splatnet, this.directory, records.records.unique_id, this.stages, - nickname_and_icons.nickname_and_icons[0], - this.favourite_stage, this.favourite_colour); - } - - if (this.results) { - await dumpResults(this.splatnet, this.directory, records.records.unique_id, - this.result_images, this.results_summary_image); - } - if (this.coop_results) { - await dumpCoopResults(this.splatnet, this.directory, records.records.unique_id); - } - } - - async handleError(err: Error | ErrorResponse): Promise { - if ('response' in err && err.data?.code === 'AUTHENTICATION_ERROR') { - // Token expired - debug('Renewing iksm_session cookie'); - - 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/common/notify.ts b/src/common/notify.ts new file mode 100644 index 0000000..84c331f --- /dev/null +++ b/src/common/notify.ts @@ -0,0 +1,405 @@ +import createDebug from 'debug'; +import persist from 'node-persist'; +import { getTitleIdFromEcUrl, hrduration, Loop, LoopResult } from '../util.js'; +import ZncApi from '../api/znc.js'; +import { ActiveEvent, Announcements, CurrentUser, Friend, Game, Presence, PresenceState, WebServices, ZncErrorResponse } from '../api/znc-types.js'; +import ZncProxyApi from '../api/znc-proxy.js'; +import { ErrorResponse } from '../api/util.js'; +import { SavedToken } from '../cli/nso/util.js'; +import { SplatNet2RecordsMonitor } from './splatnet2/monitor.js'; + +const debug = createDebug('nxapi:nso:notify'); +const debugFriends = createDebug('nxapi:nso:notify:friends'); +const debugSplatnet2 = createDebug('nxapi:nso:notify:splatnet2-monitor'); + +export class ZncNotifications extends Loop { + notifications = new NotificationManager(); + splatnet2_monitors = new Map Promise)>(); + + user_notifications = true; + friend_notifications = true; + update_interval = 30; + + constructor( + public storage: persist.LocalStorage, + public token: string, + public nso: ZncApi, + public data: Omit, + ) { + super(); + } + + async fetch(req: ( + 'announcements' | 'friends' | {friend: string; presence?: boolean} | 'webservices' | 'event' | 'user' | null + )[]) { + const result: Partial<{ + announcements: Announcements; + friends: Friend[]; + webservices: WebServices; + activeevent: ActiveEvent; + user: CurrentUser; + }> = {}; + + const friends = req.filter(r => typeof r === 'object' && r && 'friend' in r) as + {friend: string; presence?: boolean}[]; + + if (!(this.nso instanceof ZncProxyApi)) { + if (req.includes('announcements')) req.push('friends', 'webservices', 'event'); + if (req.includes('webservices')) req.push('friends', 'event'); + if (req.includes('event')) req.push('friends', 'webservices'); + } + + if (req.includes('announcements')) { + result.announcements = (await this.nso.getAnnouncements()).result; + } + if (req.includes('friends') || (friends && !(this.nso instanceof ZncProxyApi))) { + result.friends = (await this.nso.getFriendList()).result.friends; + } else if (friends && this.nso instanceof ZncProxyApi) { + result.friends = await Promise.all(friends.map(async r => { + const nso = this.nso as unknown as ZncProxyApi; + + if (r.presence) { + const friend: Friend = { + id: 0, + nsaId: r.friend, + imageUri: '', + name: '', + isFriend: true, + isFavoriteFriend: false, + isServiceUser: false, + friendCreatedAt: 0, + presence: await nso.fetch('/friend/' + r.friend + '/presence'), + }; + + return friend; + } + + return (await nso.fetch<{friend: Friend}>('/friend/' + r.friend)).friend; + })) + } + if (req.includes('webservices')) { + result.webservices = (await this.nso.getWebServices()).result; + } + if (req.includes('event')) { + const activeevent = (await this.nso.getActiveEvent()).result; + result.activeevent = 'id' in activeevent ? activeevent : undefined; + } + if (req.includes('user')) { + result.user = (await this.nso.getCurrentUser()).result; + } + + return result; + } + + async init() { + const {friends, user} = await this.fetch([ + 'announcements', + this.user_notifications ? 'user' : null, + this.friend_notifications ? 'friends' : null, + this.splatnet2_monitors.size ? 'user' : null, + ]); + + await this.updatePresenceForNotifications(user, friends, this.data.user.id, true); + if (user) await this.updatePresenceForSplatNet2Monitors([user]); + + return LoopResult.OK; + } + + async updateFriendsStatusForNotifications( + friends: (CurrentUser | Friend)[], + naid = this.data.user.id, + initialRun?: boolean + ) { + this.notifications.updateFriendsStatusForNotifications(friends, naid, initialRun); + } + + async updatePresenceForNotifications( + user: CurrentUser | undefined, friends: Friend[] | undefined, + naid = this.data.user.id, initialRun?: boolean + ) { + await this.updateFriendsStatusForNotifications(([] as (CurrentUser | Friend)[]) + .concat(this.user_notifications && user ? [user] : []) + .concat(this.friend_notifications && friends ? friends : []), naid, initialRun); + } + + async updatePresenceForSplatNet2Monitors(friends: (CurrentUser | Friend)[]) { + for (const friend of friends) { + await this.updatePresenceForSplatNet2Monitor(friend.presence, friend.nsaId, friend.name); + } + } + + async updatePresenceForSplatNet2Monitor(presence: Presence, nsa_id: string, name?: string) { + const playing = presence.state === PresenceState.PLAYING; + const monitor = this.splatnet2_monitors.get(nsa_id); + + if (playing && monitor) { + const currenttitle = presence.game as Game; + const titleid = getTitleIdFromEcUrl(currenttitle.shopUri); + + if (titleid && EmbeddedSplatNet2Monitor.title_ids.includes(titleid)) { + if ('enable' in monitor) { + monitor.enable(); + if (!monitor.enabled) debugSplatnet2('Started monitor for user %s', name ?? nsa_id); + } else { + const m = await monitor.call(null); + this.splatnet2_monitors.set(nsa_id, m); + m.enable(); + debugSplatnet2('Started monitor for user %s', name ?? nsa_id); + } + } else if ('disable' in monitor) { + if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id); + monitor.disable(); + } + } else if (monitor && 'disable' in monitor) { + if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id); + monitor.disable(); + } + } + + async update() { + const {friends, user} = await this.fetch([ + this.user_notifications ? 'user' : null, + this.friend_notifications ? 'friends' : null, + this.splatnet2_monitors.size ? 'user' : null, + ]); + + await this.updatePresenceForNotifications(user, friends); + if (user) await this.updatePresenceForSplatNet2Monitors([user]); + } + + async handleError(err: ErrorResponse | NodeJS.ErrnoException): Promise { + if (err && 'response' in err && err.data?.status === 9404) { + // Token expired + debug('Renewing token'); + + const data = await this.nso.renewToken(this.token, this.data.user); + + const existingToken: SavedToken = { + user: this.data.user, + ...data, + expires_at: Date.now() + (data.credential.expiresIn * 1000), + }; + + await this.storage.setItem('NsoToken.' + this.token, existingToken); + + 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; + } + } +} + +export class NotificationManager { + onFriendOnline?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean): void; + onFriendOffline?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean): void; + onFriendPlayingChangeTitle?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean): void; + onFriendTitleStateChange?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean): void; + + onlinefriends = new Map(); + accounts = new Map(); + + updateFriendsStatusForNotifications(friends: (CurrentUser | Friend)[], naid: string, initialRun?: boolean) { + const newonlinefriends: (CurrentUser | Friend)[] = []; + + for (const friend of friends) { + const prev = this.onlinefriends.get(naid)?.find(f => f.nsaId === friend.nsaId); + const lastpresence = prev?.presence; + const online = friend.presence.state === PresenceState.ONLINE || + friend.presence.state === PresenceState.PLAYING; + const wasonline = lastpresence?.state === PresenceState.ONLINE || + lastpresence?.state === PresenceState.PLAYING; + const consolewasonline = wasonline || + lastpresence?.state === PresenceState.INACTIVE; + + if (friend.presence.state !== PresenceState.OFFLINE || friend.presence.updatedAt) { + if (!this.accounts.has(friend.nsaId)) { + this.accounts.set(friend.nsaId, naid); + } + } + + newonlinefriends.push(friend); + + // Another account is monitoring this user's presence + if (this.accounts.get(friend.nsaId) !== naid) continue; + + if (!wasonline && online) { + // Friend has come online + const currenttitle = friend.presence.game as Game; + + debugFriends('%s is now online%s%s, title %s %s - played for %s since %s', friend.name, + friend.presence.state === PresenceState.ONLINE ? '' : ' (' + friend.presence.state + ')', + consolewasonline ? ' (console was already online)' : '', + currenttitle.name, JSON.stringify(currenttitle.sysDescription), + hrduration(currenttitle.totalPlayTime), + currenttitle.firstPlayedAt ? new Date(currenttitle.firstPlayedAt * 1000).toString() : 'now'); + + this.onFriendOnline?.(friend, prev, naid, initialRun); + + if (consolewasonline) { + // Friend's console was already online + } + } else if (wasonline && !online) { + // Friend has gone offline + const lasttitle = lastpresence.game as Game; + + debugFriends('%s is now offline%s, was playing title %s %s', friend.name, + friend.presence.state !== PresenceState.OFFLINE ? ' (console still online)' : '', + lasttitle.name, JSON.stringify(lasttitle.sysDescription)); + + this.onFriendOffline?.(friend, prev, naid, initialRun); + + if (friend.presence.state !== PresenceState.OFFLINE) { + // Friend's console is still online + } + } else if (wasonline && online) { + // Friend is still online + const lasttitle = lastpresence.game as Game; + const currenttitle = friend.presence.game as Game; + + if (getTitleIdFromEcUrl(lasttitle.shopUri) !== getTitleIdFromEcUrl(currenttitle.shopUri)) { + // Friend is playing a different title + + debugFriends('%s title is now %s %s%s, was playing %s %s%s - played for %s since %s', + friend.name, + currenttitle.name, JSON.stringify(currenttitle.sysDescription), + friend.presence.state === PresenceState.ONLINE ? '' : ' (' + friend.presence.state + ')', + lasttitle.name, JSON.stringify(lasttitle.sysDescription), + lastpresence.state === PresenceState.ONLINE ? '' : ' (' + lastpresence.state + ')', + hrduration(currenttitle.totalPlayTime), + currenttitle.firstPlayedAt ? new Date(currenttitle.firstPlayedAt * 1000).toString() : 'now'); + + this.onFriendPlayingChangeTitle?.(friend, prev, naid, initialRun); + } else if ( + lastpresence.state !== friend.presence.state || + lasttitle.sysDescription !== currenttitle.sysDescription + ) { + // Title state changed + + debugFriends('%s title %s state changed, now %s %s, was %s %s', + friend.name, currenttitle.name, + friend.presence.state, JSON.stringify(currenttitle.sysDescription), + lastpresence.state, JSON.stringify(lasttitle.sysDescription)); + + this.onFriendTitleStateChange?.(friend, prev, naid, initialRun); + } else if ( + lastpresence.state !== friend.presence.state || + lasttitle.sysDescription !== currenttitle.sysDescription + ) { + // Presence state changed (between online/playing) + + debugFriends('%s title %s state changed%s, now %s %s, was %s %s', + friend.name, currenttitle.name, + friend.presence.state, JSON.stringify(currenttitle.sysDescription), + lastpresence.state, JSON.stringify(lasttitle.sysDescription)); + } + } else if (!consolewasonline && friend.presence.state !== PresenceState.OFFLINE) { + // Friend's console is now online, but the user is not playing + + debugFriends('%s\'s console is now online', friend.name); + } else if (consolewasonline && friend.presence.state !== PresenceState.OFFLINE) { + // Friend's console is still online, the user is still not playing + } else if (consolewasonline && friend.presence.state === PresenceState.OFFLINE) { + // Friend's console is now offline + + debugFriends('%s\'s console is now offline', friend.name); + } + } + + for (const friend of this.onlinefriends.get(naid) ?? []) { + const updated = newonlinefriends.find(f => f.nsaId === friend.nsaId); + + if (!updated) { + // The authenticated user is no longer friends with this user, or received an empty presence + // object (no longer has permission to view the user's status?) + + if (this.accounts.get(friend.nsaId) !== naid) continue; + this.accounts.delete(friend.nsaId); + + for (const [naid, onlinefriends] of this.onlinefriends) { + if (onlinefriends.find(f => f.nsaId === friend.nsaId && + (f.presence.state !== PresenceState.OFFLINE || f.presence.updatedAt) + )) { + this.accounts.set(friend.nsaId, naid); + break; + } + } + } + } + + this.onlinefriends.set(naid, newonlinefriends); + } + + removeAccount(naid: string) { + const onlinefriends = this.onlinefriends.get(naid); + if (!onlinefriends) return; + + this.onlinefriends.delete(naid); + + for (const friend of onlinefriends) { + if (this.accounts.get(friend.nsaId) !== naid) continue; + this.accounts.delete(friend.nsaId); + + for (const [naid, onlinefriends] of this.onlinefriends) { + if (onlinefriends.find(f => f.nsaId === friend.nsaId && + (f.presence.state !== PresenceState.OFFLINE || f.presence.updatedAt) + )) { + this.accounts.set(friend.nsaId, naid); + break; + } + } + } + } +} + +export class EmbeddedSplatNet2Monitor extends SplatNet2RecordsMonitor { + static title_ids = [ + '0100f8f0000a2000', // Europe + '01003bc0000a0000', // The Americas + '01003c700009c000', // Japan + ]; + + enable() { + if (this._running !== 0) return; + this._run(); + } + + disable() { + this._running = 0; + } + + get enabled() { + return this._running !== 0; + } + + private _running = 0; + + private async _run() { + this._running++; + const i = this._running; + + try { + await this.loop(true); + + while (i === this._running) { + 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/common/presence.ts b/src/common/presence.ts new file mode 100644 index 0000000..f9b83f5 --- /dev/null +++ b/src/common/presence.ts @@ -0,0 +1,285 @@ +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 { DiscordPresencePlayTime, DiscordPresenceContext, getDiscordPresence, getInactiveDiscordPresence } from '../discord/util.js'; +import { ZncNotifications } from './notify.js'; +import { ErrorResponse } from '../api/util.js'; +import { getPresenceFromUrl } from '../api/znc-proxy.js'; + +const debug = createDebug('nxapi:nso:presence'); +const debugProxy = createDebug('nxapi:nso:presence:proxy'); +const debugDiscord = createDebug('nxapi:nso:presence:discordrpc'); + +export class ZncDiscordPresence extends ZncNotifications { + presence_user: string | null = null; + discord_preconnect = false; + show_friend_code = false; + force_friend_code: CurrentUser['links']['friendCode'] | undefined = undefined; + show_console_online = false; + show_active_event = true; + show_play_time = DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE; + + 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} : 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, friend); + } else { + await this.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent); + } + } + + await this.updatePresenceForNotifications(user, friends); + if (user) await this.updatePresenceForSplatNet2Monitors([user]); + + return LoopResult.OK; + } + + get presence_enabled() { + return !!this.presence_user; + } + + rpc: {client: DiscordRPC.Client, id: string} | null = null; + title: {id: string; since: number} | null = null; + i = 0; + + last_presence: Presence | null = null; + last_user: CurrentUser | Friend | undefined = undefined; + last_friendcode: CurrentUser['links']['friendCode'] | undefined = undefined; + last_event: ActiveEvent | undefined = undefined; + + async updatePresenceForDiscord( + presence: Presence | null, + user?: CurrentUser | Friend, + friendcode?: CurrentUser['links']['friendCode'], + activeevent?: ActiveEvent + ) { + this.last_presence = presence; + this.last_user = user; + this.last_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.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); + this.rpc = {client, id: discordpresence.id}; + return; + } + + if (this.rpc && !(this.presence_enabled && this.discord_preconnect)) { + const client = this.rpc.client; + this.rpc = null; + await client.destroy(); + } else if (this.rpc && this.title) { + debugDiscord('No presence but Discord preconnect enabled - clearing Discord activity'); + await this.rpc.client.clearActivity(); + } + + this.title = null; + return; + } + + const presencecontext: DiscordPresenceContext = { + friendcode: this.show_friend_code ? this.force_friend_code ?? friendcode : undefined, + activeevent: this.show_active_event ? activeevent : undefined, + show_play_time: this.show_play_time, + znc_discord_presence: this, + nsaid: this.presence_user!, + 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 = await this.createDiscordClient(discordpresence.id); + 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 createDiscordClient(clientid: string) { + let client: DiscordRPC.Client; + let attempts = 0; + let connected = false; + + 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); + connected = true; + break; + } catch (err) {} + + attempts++; + await new Promise(rs => setTimeout(rs, 5000)); + } + + if (!connected) throw new Error('Failed to connect to Discord'); + + const reconnect = async () => { + if (this.rpc?.client !== client) return; + + debugDiscord('RPC client disconnected, attempting to reconnect', clientid); + let attempts = 0; + let connected = false; + + while (attempts < 10) { + if (this.rpc?.client !== client) return; + + 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); + + // @ts-expect-error + client.transport.on('close', reconnect); + + this.rpc.client = newclient; + client = newclient; + connected = true; + break; + } catch (err) { + debugDiscord('RPC reconnect failed, attempt %d', attempts + 1, clientid, err); + } + + attempts++; + await new Promise(rs => setTimeout(rs, 5000)); + } + + if (!connected) throw new Error('Failed to reconnect to Discord'); + }; + + // @ts-expect-error + client.transport.on('close', reconnect); + + return client!; + } + + 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} : 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, friend); + } + } else { + await this.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent); + } + } + + await this.updatePresenceForNotifications(user, friends); + if (user) 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); + } +} + +export class ZncProxyDiscordPresence extends ZncDiscordPresence { + constructor( + public presence_url: string + ) { + super(null!, null!, null!, null!); + } + + get presence_enabled() { + return true; + } + + async init() { + await this.update(); + + return LoopResult.OK; + } + + async update() { + const [presence, user] = await getPresenceFromUrl(this.presence_url); + + await this.updatePresenceForDiscord(presence, user); + await this.updatePresenceForSplatNet2Monitor(presence, this.presence_url); + } +} diff --git a/src/common/splatnet2/dump-records.ts b/src/common/splatnet2/dump-records.ts new file mode 100644 index 0000000..314a4bb --- /dev/null +++ b/src/common/splatnet2/dump-records.ts @@ -0,0 +1,158 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import createDebug from 'debug'; +import fetch from 'node-fetch'; +import * as crypto from 'crypto'; +import SplatNet2Api, { ShareColour } from '../../api/splatnet2.js'; +import { Challenge, NicknameAndIcon, Records, Stages } from '../../api/splatnet2-types.js'; + +const debug = createDebug('nxapi:splatnet2:dump-records'); + +export async function dumpRecords( + directory: string, user_id: string, + records: Records, nickname_and_icon: NicknameAndIcon, + updated?: Date +) { + const latest_filename = 'splatnet2-records-' + user_id + '-latest.json'; + const latest_file = path.join(directory, latest_filename); + + if (updated) { + try { + const {sn2_updated_timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); + + if ((sn2_updated_timestamp * 1000) >= updated.getTime()) { + debug('Skipping user records, not updated'); + return; + } + } catch (err) {} + } + + const timestamp = Date.now(); + const sn2_updated_timestamp = records.records.update_time; + + const filename = 'splatnet2-records-' + user_id + '-' + timestamp + '.json'; + const file = path.join(directory, filename); + const ni_filename = 'splatnet2-ni-' + user_id + '-' + timestamp + '.json'; + const ni_file = path.join(directory, ni_filename); + + debug('Writing records %s', filename); + await fs.writeFile(file, JSON.stringify(records, null, 4) + '\n', 'utf-8'); + + debug('Writing records %s', ni_filename); + await fs.writeFile(ni_file, JSON.stringify(nickname_and_icon, null, 4) + '\n', 'utf-8'); + + await fs.writeFile(latest_file, JSON.stringify({timestamp, sn2_updated_timestamp}, null, 4) + '\n', 'utf-8'); +} + +export async function dumpProfileImage( + splatnet: SplatNet2Api, directory: string, user_id: string, + stages: Stages, nickname_and_icon: NicknameAndIcon, + favourite_stage?: string, favourite_colour?: string, + updated?: Date +) { + const stage = favourite_stage ? + stages.stages.find(s => s.id === favourite_stage || + s.name.toLowerCase() === favourite_stage?.toLowerCase()) : + stages.stages[0]; + + if (!stage) { + debug('Invalid favourite stage "%s"', favourite_stage); + } + + // @ts-expect-error + if (!Object.values(ShareColour).includes(favourite_colour)) { + favourite_colour = ShareColour.PINK; + } + + const latest_filename = 'splatnet2-profile-' + user_id + '-latest.json'; + const latest_file = path.join(directory, latest_filename); + + const etag_data = { + stage: (stage ?? stages.stages[0]).id, + stage_image: (stage ?? stages.stages[0]).image, + colour: favourite_colour, + + name: nickname_and_icon.nickname, + image_url: nickname_and_icon.thumbnail_url, + }; + const etag = crypto.createHash('sha256').update(JSON.stringify(etag_data)).digest('base64'); + + if (updated) { + try { + const {timestamp, etag: prev_etag} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); + + if (timestamp > updated.getTime() && etag === prev_etag) { + debug('Skipping profile image, not updated'); + return; + } + } catch (err) {} + } + + const timestamp = Date.now(); + const filename = 'splatnet2-profile-' + user_id + '-' + timestamp + '.json'; + const file = path.join(directory, filename); + const image_filename = 'splatnet2-profile-' + user_id + '-' + timestamp + '.png'; + const image_file = path.join(directory, image_filename); + + debug('Fetching profile image URL'); + const share = await splatnet.shareProfile(stage?.id ?? stages.stages[0].id, favourite_colour as ShareColour); + + debug('Fetching profile image'); + const image_response = await fetch(share.url); + const image = await image_response.buffer(); + + debug('Writing profile image data %s', filename); + await fs.writeFile(file, JSON.stringify({ + share, + stage: stage ?? stages.stages[0], + colour: favourite_colour, + }, null, 4) + '\n', 'utf-8'); + + debug('Writing profile image %s', image_filename); + await fs.writeFile(image_file, image); + + await fs.writeFile(latest_file, JSON.stringify({timestamp, etag, etag_data}, null, 4) + '\n', 'utf-8'); +} + +export async function dumpChallenges( + splatnet: SplatNet2Api, directory: string, user_id: string, + challenges: Challenge[], season: 1 | 2 +) { + for (const challenge of challenges) { + const filename = 'splatnet2-challenge-' + user_id + '-' + challenge.key + '.json'; + const file = path.join(directory, filename); + const image_filename = 'splatnet2-challenge-' + user_id + '-' + challenge.key + '.png'; + const image_file = path.join(directory, image_filename); + + try { + await fs.stat(file); + await fs.stat(image_file); + debug('Skipping challenge image %s, file already exists', challenge.key); + continue; + } catch (err) {} + + debug('Fetching challenge image URL for %s', challenge.key); + const share = await splatnet.shareChallenge(challenge.key, season); + + debug('Fetching challenge image for %s', challenge.key); + const image_response = await fetch(share.url); + const image = await image_response.buffer(); + + debug('Writing challenge image data %s', filename); + await fs.writeFile(file, JSON.stringify({share}, null, 4) + '\n', 'utf-8'); + + debug('Writing challenge image %s', filename); + await fs.writeFile(image_file, image); + } +} + +export async function dumpHeroRecords(splatnet: SplatNet2Api, directory: string, user_id: string) { + debug('Fetching hero records'); + const hero = await splatnet.getHeroRecords(); + + const filename = 'splatnet2-hero-' + user_id + '-' + Date.now() + '.json'; + const file = path.join(directory, filename); + + debug('Writing hero records %s', filename); + await fs.writeFile(file, JSON.stringify(hero, null, 4) + '\n', 'utf-8'); +} diff --git a/src/common/splatnet2/dump-results.ts b/src/common/splatnet2/dump-results.ts new file mode 100644 index 0000000..30403eb --- /dev/null +++ b/src/common/splatnet2/dump-results.ts @@ -0,0 +1,192 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import createDebug from 'debug'; +import fetch from 'node-fetch'; +import SplatNet2Api from '../../api/splatnet2.js'; +import { NicknameAndIcon } from '../../api/splatnet2-types.js'; + +const debug = createDebug('nxapi:splatnet2:dump-results'); + +export async function dumpResults( + splatnet: SplatNet2Api, directory: string, user_id: string, + images = false, summary_image = images, updated?: Date +) { + const latest_filename = 'splatnet2-results-summary-' + user_id + '-latest.json'; + const latest_file = path.join(directory, latest_filename); + + if (updated) { + try { + const {timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); + + if (timestamp > updated.getTime()) { + debug('Skipping battle results, user records not updated'); + return; + } + } catch (err) {} + } + + debug('Fetching battle results'); + const results = await splatnet.getResults(); + + const timestamp = Date.now(); + const summary_filename = 'splatnet2-results-summary-' + results.unique_id + '-' + timestamp + '.json'; + const summary_file = path.join(directory, summary_filename); + + debug('Writing summary %s', summary_filename); + await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8'); + + if (summary_image) { + const filename = 'splatnet2-results-summary-image-' + results.unique_id + '-' + timestamp + '.json'; + const file = path.join(directory, filename); + const image_filename = 'splatnet2-results-summary-image-' + results.unique_id + '-' + timestamp + '.png'; + const image_file = path.join(directory, image_filename); + + debug('Fetching battle results summary image URL'); + const share = await splatnet.shareResultsSummary(); + + debug('Fetching battle results summary image'); + const image_response = await fetch(share.url); + const image = await image_response.buffer(); + + debug('Writing battle results summary image data %s', filename); + await fs.writeFile(file, JSON.stringify({ + share, + }, null, 4) + '\n', 'utf-8'); + + debug('Writing battle results summary image %s', filename); + await fs.writeFile(image_file, image); + } + + const skipped = []; + const skipped_images = []; + + for (const item of results.results) { + const filename = 'splatnet2-result-' + results.unique_id + '-' + item.battle_number + '-' + item.type + '.json'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + skipped.push(item.battle_number); + } catch (err) { + debug('Fetching battle result %d', item.battle_number); + const result = await splatnet.getResult(item.battle_number); + + const nickname_and_icons: NicknameAndIcon[] = []; + + for (const playerresult of [result.player_result, ...result.my_team_members, ...result.other_team_members]) { + const nickname_and_icon = await splatnet.getUserNicknameAndIcon([playerresult.player.principal_id]); + nickname_and_icons.push(...nickname_and_icon.nickname_and_icons); + } + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result, + nickname_and_icons, + }, null, 4) + '\n', 'utf-8'); + } + + if (images) { + const filename = 'splatnet2-result-image-' + results.unique_id + '-' + + item.battle_number + '-' + item.type + '.json'; + const file = path.join(directory, filename); + const image_filename = 'splatnet2-result-image-' + results.unique_id + '-' + + item.battle_number + '-' + item.type + '.png'; + const image_file = path.join(directory, image_filename); + + try { + await fs.stat(file); + await fs.stat(image_file); + skipped_images.push(item.battle_number); + } catch (err) { + debug('Fetching battle results image URL'); + const share = await splatnet.shareResult(item.battle_number); + + debug('Fetching battle results image'); + const image_response = await fetch(share.url); + const image = await image_response.buffer(); + + debug('Writing battle results image data %s', filename); + await fs.writeFile(file, JSON.stringify({ + share, + }, null, 4) + '\n', 'utf-8'); + + debug('Writing battle results image %s', filename); + await fs.writeFile(image_file, image); + } + } + } + + if (skipped.length) { + if (skipped.length === 1) debug('Skipped battle result %d, file already exists', skipped[0]); + else debug('Skipped battle results %s, files already exist', skipped.join(', ')); + } + if (skipped_images.length) { + if (skipped_images.length === 1) debug('Skipped battle result image %d, file already exists', + skipped_images[0]); + else debug('Skipped battle result images %s, files already exist', skipped_images.join(', ')); + } + + await fs.writeFile(latest_file, JSON.stringify({timestamp}, null, 4) + '\n', 'utf-8'); +} + +export async function dumpCoopResults(splatnet: SplatNet2Api, directory: string, user_id: string, updated?: Date) { + const latest_filename = 'splatnet2-coop-summary-' + user_id + '-latest.json'; + const latest_file = path.join(directory, latest_filename); + + if (updated) { + try { + const {timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); + + if (timestamp > updated.getTime()) { + debug('Skipping coop results, user records not updated'); + return; + } + } catch (err) {} + } + + debug('Fetching coop results'); + const results = await splatnet.getCoopResults(); + + const timestamp = Date.now(); + const summary_filename = 'splatnet2-coop-summary-' + user_id + '-' + Date.now() + '.json'; + const summary_file = path.join(directory, summary_filename); + + debug('Writing summary %s', summary_filename); + await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8'); + + const skipped = []; + + for (const item of results.results) { + const filename = 'splatnet2-coop-result-' + user_id + '-' + item.job_id + '.json'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + skipped.push(item.job_id); + continue; + } catch (err) {} + + debug('Fetching coop result %d', item.job_id); + const result = await splatnet.getCoopResult(item.job_id); + + const nickname_and_icons: NicknameAndIcon[] = []; + + for (const playerresult of [result.my_result, ...result.other_results]) { + const nickname_and_icon = await splatnet.getUserNicknameAndIcon([playerresult.pid]); + nickname_and_icons.push(...nickname_and_icon.nickname_and_icons); + } + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result, + nickname_and_icons, + }, null, 4) + '\n', 'utf-8'); + } + + if (skipped.length) { + if (skipped.length === 1) debug('Skipped coop result %d, file already exists', skipped[0]); + else debug('Skipped coop results %s, files already exist', skipped.join(', ')); + } + + await fs.writeFile(latest_file, JSON.stringify({timestamp}, null, 4) + '\n', 'utf-8'); +} diff --git a/src/common/splatnet2/monitor.ts b/src/common/splatnet2/monitor.ts new file mode 100644 index 0000000..5de56ea --- /dev/null +++ b/src/common/splatnet2/monitor.ts @@ -0,0 +1,109 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import createDebug from 'debug'; +import persist from 'node-persist'; +import mkdirp from 'mkdirp'; +import SplatNet2Api from '../../api/splatnet2.js'; +import { renewIksmToken } from '../../cli/splatnet2/util.js'; +import { Loop, LoopResult } from '../../util.js'; +import { Records, Stages, WebServiceError } from '../../api/splatnet2-types.js'; +import { dumpCoopResults, dumpResults } from './dump-results.js'; +import { dumpProfileImage, dumpRecords } from './dump-records.js'; +import { ErrorResponse } from '../../api/util.js'; + +const debug = createDebug('nxapi:splatnet2:monitor'); + +export class SplatNet2RecordsMonitor extends Loop { + update_interval: number = 3 * 60; // 3 minutes in seconds + + profile_image = true; + favourite_stage: string | undefined = undefined; + favourite_colour: string | undefined = undefined; + + results = true; + results_summary_image = true; + result_images = true; + coop_results = true; + + /** Prevents redownloading user records on the first loop run */ + cached_records: Records | null = null; + + auto_update_iksm_session = true; + + constructor( + public storage: persist.LocalStorage, + public token: string, + public splatnet: SplatNet2Api, + public stages: Stages, + public directory: string, + public znc_proxy_url?: string + ) { + super(); + } + + async init() { + await mkdirp(this.directory); + } + + async hasChanged(records: Records) { + const latest_filename = 'splatnet2-records-' + records.records.unique_id + '-latest.json'; + const latest_file = path.join(this.directory, latest_filename); + + const updated = new Date(records.records.update_time * 1000); + + try { + const {sn2_updated_timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); + + return (sn2_updated_timestamp * 1000) < updated.getTime(); + } catch (err) {} + + return true; + } + + async update() { + const records = this.cached_records ?? await this.splatnet.getRecords(); + this.cached_records = null; + + if (!(await this.hasChanged(records))) { + debug('No data changed'); + return; + } + + const nickname_and_icons = await this.splatnet.getUserNicknameAndIcon([records.records.player.principal_id]); + + // This will also update the splatnet2-records-...-latest.json file + await dumpRecords(this.directory, records.records.unique_id, records, + nickname_and_icons.nickname_and_icons[0]); + + if (this.profile_image) { + await dumpProfileImage(this.splatnet, this.directory, records.records.unique_id, this.stages, + nickname_and_icons.nickname_and_icons[0], + this.favourite_stage, this.favourite_colour); + } + + if (this.results) { + await dumpResults(this.splatnet, this.directory, records.records.unique_id, + this.result_images, this.results_summary_image); + } + if (this.coop_results) { + await dumpCoopResults(this.splatnet, this.directory, records.records.unique_id); + } + } + + async handleError(err: Error | ErrorResponse): Promise { + if ('response' in err && err.data?.code === 'AUTHENTICATION_ERROR') { + // Token expired + debug('Renewing iksm_session cookie'); + + 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/discord/titles/README.md b/src/discord/titles/README.md index 0e5c1b0..2d4578f 100644 --- a/src/discord/titles/README.md +++ b/src/discord/titles/README.md @@ -52,6 +52,25 @@ nxapi pctl daily-summaries 0123456789abcdef --json | jq '[ .items | .[] | .playe nxapi pctl monthly-summary 0123456789abcdef 2022-03 --json | jq '.playedApps | unique_by(.applicationId)' ``` -### 4. titledb +### 4. Captures + +Screenshots and screen recordings taken using the capture button are saved to the SD card (or system memory) using the following path format: + +``` +Album/YYYY/MM/DD/YYYYMMDDHHMMSSII-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC.jpg +``` + +`Y`, `M`, `D`, `H`, `M` and `S` are the timestamp, and `I` is used to identify multiple captures taken on the same second. + +`C` is the capture ID, which is used to identify the application/applet open when the capture was taken. The capture ID is just the title ID encrypted using a static key. + +Capture IDs can be decrypted using nxapi by running: + +```sh +# 397A963DA4660090D65D330174AC6B04 is the capture ID +nxapi util captureid decrypt 397A963DA4660090D65D330174AC6B04 +``` + +### 5. titledb Title IDs for most titles can be found at https://github.com/blawar/titledb (although some demo title IDs are unavailable). diff --git a/src/discord/util.ts b/src/discord/util.ts index 52ae7a3..881e9df 100644 --- a/src/discord/util.ts +++ b/src/discord/util.ts @@ -2,7 +2,7 @@ import DiscordRPC from 'discord-rpc'; import { ActiveEvent, CurrentUser, Friend, Game, PresenceState } from '../api/znc-types.js'; import { defaultTitle, titles } from './titles.js'; import { dev, getTitleIdFromEcUrl, git, hrduration, version } from '../util.js'; -import { ZncDiscordPresence } from '../cli/nso/presence.js'; +import { ZncDiscordPresence } from '../common/presence.js'; const product = 'nxapi ' + version + (git ? '-' + git.revision.substr(0, 7) + (git.branch ? ' (' + git.branch + ')' : dev ? '-dev' : '') : ''); diff --git a/src/util.ts b/src/util.ts index e4bc570..1c1ce33 100644 --- a/src/util.ts +++ b/src/util.ts @@ -8,7 +8,7 @@ import persist from 'node-persist'; import getPaths from 'env-paths'; import fetch from 'node-fetch'; -const debug = createDebug('cli'); +const debug = createDebug('nxapi:util'); export const paths = getPaths('nxapi');