From 8b28ffd4caaccb0c131f1097165935e3028dff7b Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Tue, 22 Mar 2022 22:13:21 +0000 Subject: [PATCH] Add monitoring of SplatNet 2 data --- README.md | 138 +++++++++++++++++++++++- src/api/splatnet2-types.ts | 77 ++++++++++++-- src/api/znc-types.ts | 6 +- src/cli/nso/notify.ts | 174 +++++++++++++++++++++++++++++- src/cli/nso/presence.ts | 104 +++++++++++++++--- src/cli/splatnet2.ts | 2 +- src/cli/splatnet2/index.ts | 1 + src/cli/splatnet2/monitor.ts | 198 +++++++++++++++++++++++++++++++++++ 8 files changed, 666 insertions(+), 34 deletions(-) create mode 100644 src/cli/splatnet2/monitor.ts diff --git a/README.md b/README.md index 5832e0d..144e114 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,136 @@ Alternatively nxapi includes a custom server using Frida on an Android device/em This is only required for Nintendo Switch Online app data. Nintendo Switch Parental Controls data can be fetched without sending an access token to a third-party API. +### SplatNet 2 + +All SplatNet 2 commands may automatically request a web service token. This will involve the splatnet2statink and flapg APIs (or a custom server). This can be disabled by setting `--no-auto-update-iksm-session`, however this will cause commands to fail if there isn't a valid SplatNet 2 token. + +#### User + +```sh +# Show the authenticated SplatNet 2 user +nxapi splatnet2 user +``` + +#### Download user records + +```sh +# Download user records to data/splatnet2 +# Data that already exists will not be redownloaded +nxapi splatnet2 dump-records data/splatnet2 +# Don't include user records (when downloading other data) +nxapi splatnet2 dump-records data/splatnet2 --no-user-records + +# Include lifetime inkage challenge images +nxapi splatnet2 dump-records data/splatnet2 --challenges + +# Include profile image (share button on the home page) +nxapi splatnet2 dump-records data/splatnet2 --profile-image +nxapi splatnet2 dump-records data/splatnet2 --profile-image --favourite-stage "Starfish Mainstage" --favourite-colour purple + +# Download user records even if they already exist and haven't been updated +nxapi splatnet2 dump-records data/splatnet2 --no-new-records + +# Include hero (Octo Canyon) records +# If this option is included hero records will always be downloaded even if they haven't been updated +nxapi splatnet2 dump-records data/splatnet2 --hero-records +# Only download hero records +nxapi splatnet2 dump-records data/splatnet2 --no-user-records --hero-records + +# Include timeline (CPOD FM on the home page) +# If this option is included the timeline will always be downloaded even if they haven't been updated +nxapi splatnet2 dump-records data/splatnet2 --timeline +# Only download the timeline +nxapi splatnet2 dump-records data/splatnet2 --no-user-records --timeline +``` + +#### Download battle/Salmon Run results + +```sh +# Download battle and Salmon Run results to data/splatnet2 +# Data that already exists will not be redownloaded +nxapi splatnet2 dump-results data/splatnet2 + +# Include battle summary image (share button on the battles list) +nxapi splatnet2 dump-results data/splatnet2 --battle-summary-image + +# Include battle result images (share button on the battle details page) +nxapi splatnet2 dump-results data/splatnet2 --battle-images + +# Only download battle results +nxapi splatnet2 dump-results data/splatnet2 --no-coop +# Only download Salmon Run results +nxapi splatnet2 dump-results data/splatnet2 --no-battles + +# Download summary data even if user records haven't been updated +# Individual battle results/images/Salmon Run results still won't be redownloaded if they exist +nxapi splatnet2 dump-results data/splatnet2 --no-check-updated +``` + +#### Monitor SplatNet 2 for new user records/battle/Salmon Run results + +This will constantly check SplatNet 2 for new data. + +```sh +# Watch for new battle and Salmon Run results and download them to data/splatnet2 +nxapi splatnet2 monitor data/splatnet2 + +# Include profile image (share button on the home page) +nxapi splatnet2 monitor data/splatnet2 --profile-image +nxapi splatnet2 monitor data/splatnet2 --profile-image --favourite-stage "Starfish Mainstage" --favourite-colour purple + +# Include battle summary image (share button on the battles list) +nxapi splatnet2 monitor data/splatnet2 --battle-summary-image + +# Include battle result images (share button on the battle details page) +nxapi splatnet2 monitor data/splatnet2 --battle-images + +# Only download battle results +nxapi splatnet2 monitor data/splatnet2 --no-coop +# Only download Salmon Run results +nxapi splatnet2 monitor data/splatnet2 --no-battles + +# Set update interval to 1800 seconds (30 minutes) +nxapi splatnet2 monitor data/splatnet2 --update-interval 1800 +``` + +SplatNet 2 monitoring can also be used with `nxapi nso notify` and `nxapi nso presence`. Data will only be downloaded from SplatNet 2 if the authenticated user is playing Splatoon 2 online. + +This can be used with `nxapi nso presence --presence-url ...` (the presence URL must return the status of the user authenticating to SplatNet 2). When used with `--friend-naid` the friend's presence will be shared on Discord but the authenticated user's presence will still be used to check if SplatNet 2 data should be updated. + +```sh +# Watch for new battle and Salmon Run results and download them to data/splatnet2 +nxapi nso notify --splatoon2-monitor-directory data/splatnet2 + +nxapi nso presence --splatoon2-monitor-directory data/splatnet2 + +# Include profile image (share button on the home page) +nxapi nso notify --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-profile-image +nxapi nso presence --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-profile-image + +nxapi nso notify --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-profile-image --splatoon2-monitor-favourite-stage "Starfish Mainstage" --splatoon2-monitor-favourite-colour purple +nxapi nso presence --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-profile-image --splatoon2-monitor-favourite-stage "Starfish Mainstage" --splatoon2-monitor-favourite-colour purple + +# Include battle summary image (share button on the battles list) +nxapi nso notify --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-battle-summary-image +nxapi nso presence --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-battle-summary-image + +# Include battle result images (share button on the battle details page) +nxapi nso notify --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-battle-images +nxapi nso presence --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-battle-images + +# Only download battle results +nxapi nso notify --splatoon2-monitor-directory data/splatnet2 --no-splatoon2-monitor-coop +nxapi nso presence --splatoon2-monitor-directory data/splatnet2 --no-splatoon2-monitor-coop +# Only download Salmon Run results +nxapi nso notify --splatoon2-monitor-directory data/splatnet2 --no-splatoon2-monitor-battles +nxapi nso presence --splatoon2-monitor-directory data/splatnet2 --no-splatoon2-monitor-battles + +# Set update interval to 60 seconds +nxapi nso notify --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-update-interval 60 +nxapi nso presence --splatoon2-monitor-directory data/splatnet2 --splatoon2-monitor-update-interval 60 +``` + ### Nintendo Switch Parental Controls #### Login to the Nintendo Switch Parental Controls app @@ -259,14 +389,14 @@ nxapi pctl monthly-summary 0123456789abcdef 2022-02 --json-pretty-print #### Download summary data ```sh -# Download all daily and monthly summary data from all devices to ./summaries +# Download all daily and monthly summary data from all devices to data/summaries # Data that already exists will not be redownloaded -nxapi pctl dump-summaries ./summaries +nxapi pctl dump-summaries data/summaries -# Download all daily and monthly summary data from a specific device to ./summaries +# Download all daily and monthly summary data from a specific device to data/summaries # Use `nxapi pctl devices` to get the device ID # The `--device` option can be used multiple times -nxapi pctl dump-summaries summaries --device 0123456789abcdef +nxapi pctl dump-summaries data/summaries --device 0123456789abcdef ``` ### Misc. commands/options diff --git a/src/api/splatnet2-types.ts b/src/api/splatnet2-types.ts index 00f90f2..056b54d 100644 --- a/src/api/splatnet2-types.ts +++ b/src/api/splatnet2-types.ts @@ -38,13 +38,13 @@ interface Challenges { archived_challenges: Challenge[]; next_challenge_octa: OctaChallenge; } -interface Challenge { +export interface Challenge { image: string; key: string; name: string; paint_points: number; } -interface OctaChallenge { +export interface OctaChallenge { key: string; paint_points: number; url: null; @@ -172,7 +172,7 @@ export interface Stages { stages: Stage[]; } -interface Stage { +export interface Stage { image: string; name: string; id: string; @@ -205,7 +205,7 @@ export interface Timeline { next_challenge: Challenge; total_paint_point: number; importance: number; - last_archived_challenge: Challenge; + last_archived_challenge?: Challenge; }; schedule: { schedules: { @@ -447,7 +447,7 @@ interface FestivalResults1 { }; } -/** GET /league_match_ranking/{league}/ALL */ +/** GET /league_match_ranking/{league}/{region} */ export interface LeagueMatchRankings { start_time: number; league_type: { @@ -491,23 +491,46 @@ export interface Results { }; } -interface MatchResults { +type MatchResults = RegularMatchResults | RankedMatchResults; +interface BaseMatchResults { battle_number: string; - my_team_percentage: number; type: string; start_time: number; - win_meter: number; player_result: PlayerResult; rule: Rule; star_rank: number; stage: Stage; - other_team_percentage: number; other_team_result: TeamResult; weapon_paint_point: number; player_rank: number; game_mode: GameMode; my_team_result: TeamResult; } +interface RegularMatchResults extends BaseMatchResults { + type: 'regular'; + my_team_percentage: number; + other_team_percentage: number; + win_meter: number; +} +interface RankedMatchResults extends BaseMatchResults { + type: 'gachi'; + player_result: SelfRankedPlayerResult; + my_team_count: number; + other_team_count: number; + estimate_x_power: null; + elapsed_time: number; + rank: null; + crown_players: null; + udemae: { + is_x: boolean; + is_number_reached: boolean; + s_plus_number: null; + name: null; + number: number; + }; + estimate_gachi_power: null; + x_power: null; +} interface PlayerResult { kill_count: number; death_count: number; @@ -517,6 +540,26 @@ interface PlayerResult { sort_score: number; game_paint_point: number; } +interface RankedPlayerResult extends PlayerResult { + player: PlayerResult['player'] & { + udemae: { + name: null; + s_plus_number: null; + is_x: boolean; + }; + }; +} +interface SelfRankedPlayerResult extends PlayerResult { + player: PlayerResult['player'] & { + udemae: { + name: null; + s_plus_number: null; + number: number; // -1; + is_number_reached: boolean; + is_x: boolean; + }; + }; +} interface Rule { multiline_name: string; name: string; @@ -532,10 +575,15 @@ interface TeamResult { } /** GET /results/1 */ -export interface Result extends MatchResults { +export type Result = RegularResult | RankedResult; +export interface RegularResult extends RegularMatchResults { other_team_members: PlayerResult[]; my_team_members: PlayerResult[]; } +export interface RankedResult extends RankedMatchResults { + other_team_members: RankedPlayerResult[]; + my_team_members: RankedPlayerResult[]; +} /** GET /coop_results */ export interface CoopResults { @@ -679,3 +727,12 @@ export interface ShareResponse { hashtags: string[]; text: string; } + +export interface ResultWithPlayerNicknameAndIcons { + result: Result; + nickname_and_icons: NicknameAndIcon[]; +} +export interface CoopResultWithPlayerNicknameAndIcons { + result: CoopResult; + nickname_and_icons: NicknameAndIcon[]; +} diff --git a/src/api/znc-types.ts b/src/api/znc-types.ts index 2d70f91..32a3026 100644 --- a/src/api/znc-types.ts +++ b/src/api/znc-types.ts @@ -64,7 +64,11 @@ export enum PresenceState { INACTIVE = 'INACTIVE', /** The user is selected in an application */ ONLINE = 'ONLINE', - /** The user is selected in an application (and I assume playing online?) */ + /** + * The user is selected in an application and playing online. + * (Is this set by Nintendo's servers if the user is in a session on Nintendo's servers, or by the application + * running on the console?) + */ PLAYING = 'PLAYING', } diff --git a/src/cli/nso/notify.ts b/src/cli/nso/notify.ts index 550147f..abc843b 100644 --- a/src/cli/nso/notify.ts +++ b/src/cli/nso/notify.ts @@ -1,14 +1,17 @@ import createDebug from 'debug'; import persist from 'node-persist'; import notifier from 'node-notifier'; -import { CurrentUser, Friend, Game, PresenceState } from '../../api/znc-types.js'; +import { CurrentUser, Friend, Game, Presence, PresenceState, ZncSuccessResponse } from '../../api/znc-types.js'; import ZncApi from '../../api/znc.js'; import type { Arguments as ParentArguments } from '../nso.js'; import { ArgumentsCamelCase, Argv, getTitleIdFromEcUrl, getToken, hrduration, initStorage, SavedToken, YargsArguments } from '../../util.js'; import ZncProxyApi from '../../api/znc-proxy.js'; +import { SplatNet2RecordsMonitor } from '../splatnet2/monitor.js'; +import { getIksmToken } from '../splatnet2/util.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'; @@ -32,14 +35,48 @@ export function builder(yargs: Argv) { describe: 'Update interval in seconds', type: 'number', default: 30, + }).option('splatnet2-monitor-directory', { + describe: 'Directory to write SplatNet 2 record data to', + type: 'string', + }).option('splatnet2-monitor-profile-image', { + describe: 'Include profile image', + type: 'boolean', + default: false, + }).option('splatnet2-monitor-favourite-stage', { + describe: 'Favourite stage to include on profile image', + type: 'string', + }).option('splatnet2-monitor-favourite-colour', { + describe: 'Favourite colour to include on profile image', + type: 'string', + }).option('splatnet2-monitor-battles', { + describe: 'Include regular/ranked/private/festival battle results', + type: 'boolean', + default: true, + }).option('splatnet2-monitor-battle-summary-image', { + describe: 'Include regular/ranked/private/festival battle summary image', + type: 'boolean', + default: false, + }).option('splatnet2-monitor-battle-images', { + describe: 'Include regular/ranked/private/festival battle result images', + type: 'boolean', + default: false, + }).option('splatnet2-monitor-coop', { + describe: 'Include coop (Salmon Run) results', + type: 'boolean', + default: true, + }).option('splatnet2-monitor-update-interval', { + describe: 'Update interval in seconds', + type: 'number', + // 3 minutes - the monitor is only active while the authenticated user is playing Splatoon 2 online + default: 3 * 60, }); } type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { - if (!argv.userNotifications && !argv.friendNotifications) { - throw new Error('Must enable either user or friend notifications'); + if (!argv.userNotifications && !argv.friendNotifications && !argv.splatnet2MonitorDirectory) { + throw new Error('Must enable either user notifications, friend notifications, or SplatNet 2 monitoring'); } const storage = await initStorage(argv.dataPath); @@ -54,6 +91,13 @@ export async function handler(argv: ArgumentsCamelCase) { console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)', data.user.screenName, data.user.nickname, data.nsoAccount.user.name); + if (argv.splatnet2MonitorDirectory) { + console.warn('SplatNet 2 monitoring enabled for %s (NA %s, NSO %s) - SplatNet 2 records will be ' + + 'downloaded when this user is playing Splatoon 2 online.', + data.user.screenName, data.user.nickname, data.nsoAccount.user.name); + i.splatnet2_monitors.set(data.nsoAccount.user.nsaId, handleEnableSplatNet2Monitoring(argv, storage, token)); + } + await i.init(); while (true) { @@ -62,6 +106,8 @@ export async function handler(argv: ArgumentsCamelCase) { } export class ZncNotifications { + splatnet2_monitors = new Map Promise)>(); + constructor( readonly argv: ArgumentsCamelCase, public storage: persist.LocalStorage, @@ -79,15 +125,23 @@ export class ZncNotifications { const activeevent = await this.nso.getActiveEvent(); } + let user: ZncSuccessResponse | null = null; + if (this.argv.userNotifications) { - const user = await this.nso.getCurrentUser(); + user = await this.nso.getCurrentUser(); await this.updateFriendsStatusForNotifications(this.argv.friendNotifications ? [user.result, ...friends.result.friends] : [user.result]); - } else { + } else if (this.argv.friendNotifications) { await this.updateFriendsStatusForNotifications(friends.result.friends); } + if (this.argv.splatnet2MonitorDirectory) { + if (!user) user = await this.nso.getCurrentUser(); + + await this.updatePresenceForSplatNet2Monitors([user.result]); + } + await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000)); } @@ -243,6 +297,40 @@ export class ZncNotifications { this.onlinefriends = newonlinefriends; } + 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() { debug('Updating presence'); @@ -293,3 +381,79 @@ export class ZncNotifications { } } } + +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.init(); + + while (i === this._running) { + await this.loop(); + } + + // Run one more time after the loop ends + await this.loop(); + } finally { + this._running = 0; + } + } +} + +export function handleEnableSplatNet2Monitoring( + argv: ArgumentsCamelCase, storage: persist.LocalStorage, token: string +) { + return async () => { + const {splatnet, data} = await getIksmToken(storage, token, argv.zncProxyUrl); + + const records = await splatnet.getRecords(); + const stages = await splatnet.getStages(); + + const i = new EmbeddedSplatNet2Monitor(storage, token, splatnet, stages, argv.splatnet2MonitorDirectory!, argv.zncProxyUrl); + + i.update_interval = argv.splatnet2MonitorUpdateInterval; + + i.profile_image = argv.splatnet2MonitorProfileImage; + i.favourite_stage = argv.splatnet2MonitorFavouriteStage; + i.favourite_colour = argv.splatnet2MonitorFavouriteColour; + + i.results = argv.splatnet2MonitorBattles; + i.results_summary_image = argv.splatnet2MonitorBattleSummaryImage; + i.result_images = argv.splatnet2MonitorBattleImages; + i.coop_results = argv.splatnet2MonitorCoop; + + i.cached_records = records; + + console.log('Player %s (Splatoon 2 ID %s, NSA ID %s) level %d', + records.records.player.nickname, + records.records.unique_id, + records.records.player.principal_id, + records.records.player.player_rank, + records.records.player.player_type); + + return i; + }; +} diff --git a/src/cli/nso/presence.ts b/src/cli/nso/presence.ts index a08d799..a413c7d 100644 --- a/src/cli/nso/presence.ts +++ b/src/cli/nso/presence.ts @@ -2,12 +2,12 @@ import createDebug from 'debug'; import persist from 'node-persist'; import DiscordRPC from 'discord-rpc'; import fetch from 'node-fetch'; -import { CurrentUser, Friend, Presence, PresenceState } from '../../api/znc-types.js'; +import { CurrentUser, Friend, Presence, PresenceState, ZncSuccessResponse } from '../../api/znc-types.js'; import ZncApi from '../../api/znc.js'; import type { Arguments as ParentArguments } from '../nso.js'; import { ArgumentsCamelCase, Argv, getToken, initStorage, SavedToken, YargsArguments } from '../../util.js'; import { getDiscordPresence, getInactiveDiscordPresence } from '../../discord/util.js'; -import { ZncNotifications } from './notify.js'; +import { handleEnableSplatNet2Monitoring, ZncNotifications } from './notify.js'; import ZncProxyApi from '../../api/znc-proxy.js'; const debug = createDebug('cli:nso:presence'); @@ -29,7 +29,7 @@ export function builder(yargs: Argv) { type: 'boolean', default: false, }).option('friend-naid', { - describe: 'Friend\'s Nintendo Account ID', + describe: 'Friend\'s Nintendo Switch account ID', type: 'string', }).option('friend-code', { describe: 'Friend code', @@ -49,6 +49,40 @@ export function builder(yargs: Argv) { }).option('presence-url', { describe: 'URL to get user presence from, for use with `nxapi nso http-server`', type: 'string', + }).option('splatnet2-monitor-directory', { + describe: 'Directory to write SplatNet 2 record data to', + type: 'string', + }).option('splatnet2-monitor-profile-image', { + describe: 'Include profile image', + type: 'boolean', + default: false, + }).option('splatnet2-monitor-favourite-stage', { + describe: 'Favourite stage to include on profile image', + type: 'string', + }).option('splatnet2-monitor-favourite-colour', { + describe: 'Favourite colour to include on profile image', + type: 'string', + }).option('splatnet2-monitor-battles', { + describe: 'Include regular/ranked/private/festival battle results', + type: 'boolean', + default: true, + }).option('splatnet2-monitor-battle-summary-image', { + describe: 'Include regular/ranked/private/festival battle summary image', + type: 'boolean', + default: false, + }).option('splatnet2-monitor-battle-images', { + describe: 'Include regular/ranked/private/festival battle result images', + type: 'boolean', + default: false, + }).option('splatnet2-monitor-coop', { + describe: 'Include coop (Salmon Run) results', + type: 'boolean', + default: true, + }).option('splatnet2-monitor-update-interval', { + describe: 'Update interval in seconds', + type: 'number', + // 3 minutes - the monitor is only active while the authenticated user is playing Splatoon 2 online + default: 3 * 60, }); } @@ -56,15 +90,34 @@ type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { if (argv.presenceUrl) { - if (argv.token) throw new Error('--presence-url not compatible with --user'); - if (argv.token) throw new Error('--presence-url not compatible with --token'); if (argv.friendNaid) throw new Error('--presence-url not compatible with --friend-naid'); if (argv.userNotifications) throw new Error('--presence-url not compatible with --user-notifications'); if (argv.friendNotifications) throw new Error('--presence-url not compatible with --user-notifications'); const i = new ZncProxyDiscordPresence(argv, argv.presenceUrl); - console.warn('Not authenticated; using znc proxy'); + if (argv.splatnet2MonitorDirectory) { + const storage = await initStorage(argv.dataPath); + + const usernsid = argv.user ?? await storage.getItem('SelectedUser'); + const token: string = argv.token || + await storage.getItem('NintendoAccountToken.' + usernsid); + const {nso, data} = await getToken(storage, token, argv.zncProxyUrl); + + console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)', + data.user.screenName, data.user.nickname, data.nsoAccount.user.name); + console.warn('SplatNet 2 monitoring is enabled for %s (NA %s, NSO %s), but using znc proxy for ' + + 'presence. The presence URL must return the presence of the authenticated user for SplatNet 2 ' + + 'monitoring to work.', + data.user.screenName, data.user.nickname, data.nsoAccount.user.name); + + i.splatnet2_monitors.set(argv.presenceUrl, handleEnableSplatNet2Monitoring(argv, storage, token)); + } else { + if (argv.user) throw new Error('--presence-url not compatible with --user'); + if (argv.token) throw new Error('--presence-url not compatible with --token'); + + console.warn('Not authenticated; using znc proxy'); + } await i.init(); @@ -87,6 +140,14 @@ export async function handler(argv: ArgumentsCamelCase) { console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)', data.user.screenName, data.user.nickname, data.nsoAccount.user.name); + if (argv.splatnet2MonitorDirectory) { + if (argv.friendNaid) { + console.warn('SplatNet 2 monitoring is enabled, but --friend-naid is set. SplatNet 2 records will only be downloaded when the authenticated user is playing Splatoon 2 online, regardless of the --friend-naid user.'); + } + + i.splatnet2_monitors.set(data.nsoAccount.user.nsaId, handleEnableSplatNet2Monitoring(argv, storage, token)); + } + await i.init(); while (true) { @@ -132,6 +193,8 @@ class ZncDiscordPresence extends ZncNotifications { await this.nso.getActiveEvent(); } + let user: ZncSuccessResponse | null = null; + if (this.argv.friendNaid) { const friend = friends.result.friends.find(f => f.nsaId === this.argv.friendNaid); @@ -150,7 +213,7 @@ class ZncDiscordPresence extends ZncNotifications { await this.updatePresence(friend.presence); } else { - const user = await this.nso.getCurrentUser(); + user = await this.nso.getCurrentUser(); if (this.argv.friendNotifications) { await this.updateFriendsStatusForNotifications(this.argv.userNotifications ? @@ -162,6 +225,12 @@ class ZncDiscordPresence extends ZncNotifications { await this.updatePresence(user.result.presence, user.result.links.friendCode); } + if (this.argv.splatnet2MonitorDirectory) { + if (!user) user = await this.nso.getCurrentUser(); + + await this.updatePresenceForSplatNet2Monitors([user.result]); + } + await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000)); } @@ -267,6 +336,8 @@ class ZncDiscordPresence extends ZncNotifications { } async update() { + let user: CurrentUser | null = null; + if (this.argv.friendNaid) { if (!(this.nso instanceof ZncProxyApi)) await this.nso.getActiveEvent(); const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ? @@ -303,11 +374,11 @@ class ZncDiscordPresence extends ZncNotifications { await this.updatePresence(friend.presence); } else { - const user = !(this.nso instanceof ZncProxyApi) ? - await this.nso.getCurrentUser() : {result: { + user = !(this.nso instanceof ZncProxyApi) ? + (await this.nso.getCurrentUser()).result : { ...this.data.nsoAccount.user, presence: await this.nso.fetch('/user/presence'), - }}; + }; if (this.argv.friendNotifications) { if (!(this.nso instanceof ZncProxyApi)) await this.nso.getActiveEvent(); @@ -315,12 +386,18 @@ class ZncDiscordPresence extends ZncNotifications { if (!(this.nso instanceof ZncProxyApi)) await this.nso.getWebServices(); await this.updateFriendsStatusForNotifications(this.argv.userNotifications ? - [user.result, ...friends.result.friends] : friends.result.friends); + [user, ...friends.result.friends] : friends.result.friends); } else if (this.argv.userNotifications) { - await this.updateFriendsStatusForNotifications([user.result]); + await this.updateFriendsStatusForNotifications([user]); } - await this.updatePresence(user.result.presence, user.result.links.friendCode); + await this.updatePresence(user.presence, user.links.friendCode); + } + + if (this.argv.splatnet2MonitorDirectory) { + if (!user) user = (await this.nso.getCurrentUser()).result; + + await this.updatePresenceForSplatNet2Monitors([user]); } } } @@ -349,5 +426,6 @@ class ZncProxyDiscordPresence extends ZncDiscordPresence { const presence = await response.json() as Presence; await this.updatePresence(presence); + await this.updatePresenceForSplatNet2Monitor(presence, this.presence_url); } } diff --git a/src/cli/splatnet2.ts b/src/cli/splatnet2.ts index 132499b..2a3e68e 100644 --- a/src/cli/splatnet2.ts +++ b/src/cli/splatnet2.ts @@ -20,7 +20,7 @@ export function builder(yargs: Argv) { }).option('auto-update-iksm-session', { describe: 'Automatically obtain and refresh the iksm_session cookie', type: 'boolean', - default: false, + default: true, }); } diff --git a/src/cli/splatnet2/index.ts b/src/cli/splatnet2/index.ts index 82ef2d7..b6bb63a 100644 --- a/src/cli/splatnet2/index.ts +++ b/src/cli/splatnet2/index.ts @@ -1,3 +1,4 @@ export * as user from './user.js'; export * as dumpResults from './dump-results.js'; export * as dumpRecords from './dump-records.js'; +export * as monitor from './monitor.js'; diff --git a/src/cli/splatnet2/monitor.ts b/src/cli/splatnet2/monitor.ts new file mode 100644 index 0000000..5bca2d4 --- /dev/null +++ b/src/cli/splatnet2/monitor.ts @@ -0,0 +1,198 @@ +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, YargsArguments } from '../../util.js'; +import { Records, Stages, WebServiceError } from '../../api/splatnet2-types.js'; +import { Arguments as ParentArguments } from '../splatnet2.js'; +import { dumpCoopResults, dumpResults } from './dump-results.js'; +import { dumpProfileImage, dumpRecords } from './dump-records.js'; + +const debug = createDebug('cli:splatnet2:monitor'); + +export const command = 'monitor '; +export const desc = 'Monitor SplatNet 2 for new user records/battles/Salmon Run results'; + +export function builder(yargs: Argv) { + return yargs.positional('directory', { + describe: 'Directory to write record data to', + type: 'string', + demandOption: true, + }).option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).option('profile-image', { + describe: 'Include profile image', + type: 'boolean', + default: false, + }).option('favourite-stage', { + describe: 'Favourite stage to include on profile image', + type: 'string', + }).option('favourite-colour', { + describe: 'Favourite colour to include on profile image', + type: 'string', + }).option('battles', { + describe: 'Include regular/ranked/private/festival battle results', + type: 'boolean', + default: true, + }).option('battle-summary-image', { + describe: 'Include regular/ranked/private/festival battle summary image', + type: 'boolean', + default: false, + }).option('battle-images', { + describe: 'Include regular/ranked/private/festival battle result images', + type: 'boolean', + default: false, + }).option('coop', { + describe: 'Include coop (Salmon Run) results', + type: 'boolean', + default: true, + }).option('update-interval', { + describe: 'Update interval in seconds', + type: 'number', + // 15 minutes to match splatnet2statink + // When used with `nxapi nso notify/presence` this is 3 minutes by default, as the monitor is only + // updated when playing Splatoon 2 online + default: 15 * 60, + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + const storage = await initStorage(argv.dataPath); + + const usernsid = argv.user ?? await storage.getItem('SelectedUser'); + const token: string = argv.token || + await storage.getItem('NintendoAccountToken.' + usernsid); + const {splatnet, data} = await getIksmToken(storage, token, argv.zncProxyUrl); + + const records = await splatnet.getRecords(); + const stages = await splatnet.getStages(); + + const i = new SplatNet2RecordsMonitor(storage, token, splatnet, stages, argv.directory, argv.zncProxyUrl); + + i.update_interval = argv.updateInterval; + + i.profile_image = argv.profileImage; + i.favourite_stage = argv.favouriteStage; + i.favourite_colour = argv.favouriteColour; + + i.results = argv.battles; + i.results_summary_image = argv.battleSummaryImage; + i.result_images = argv.battleImages; + i.coop_results = argv.coop; + + i.cached_records = records; + + console.log('Player %s (Splatoon 2 ID %s, NSA ID %s) level %d', + records.records.player.nickname, + records.records.unique_id, + records.records.player.principal_id, + records.records.player.player_rank, + records.records.player.player_type); + + await i.init(); + + while (true) { + await i.loop(); + } +} + +export class SplatNet2RecordsMonitor { + 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; + + constructor( + public storage: persist.LocalStorage, + public token: string, + public splatnet: SplatNet2Api, + public stages: Stages, + public directory: string, + public znc_proxy_url?: string + ) {} + + 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 {timestamp} = JSON.parse(await fs.readFile(latest_file, 'utf-8')); + + return timestamp <= 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 loop() { + try { + await this.update(); + + await new Promise(rs => setTimeout(rs, this.update_interval * 1000)); + } catch (err) { + // @ts-expect-error + if ((err?.data as WebServiceError)?.code === 'AUTHENTICATION_ERROR') { + // Token expired + debug('Renewing iksm_session cookie'); + + await renewIksmToken(this.splatnet, this.storage, this.token, this.znc_proxy_url); + } else { + throw err; + } + } + } +}