Move monitoring classes, use nxapi debug namespace for non-app/cli modules

This commit is contained in:
Samuel Elliott 2022-05-14 16:57:44 +01:00
parent 1efdfc9deb
commit b531fcced4
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
23 changed files with 1261 additions and 1205 deletions

View File

@ -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

View File

@ -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');

View File

@ -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';

View File

@ -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');

View File

@ -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';

View File

@ -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/';

View File

@ -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;

View File

@ -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';

View File

@ -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;

View File

@ -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<Arguments>) {
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);

View File

@ -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<Arguments>) {
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<Arguments>) {
}
}
export class ZncNotifications extends Loop {
notifications = new NotificationManager();
splatnet2_monitors = new Map<string, EmbeddedSplatNet2Monitor | (() => Promise<EmbeddedSplatNet2Monitor>)>();
user_notifications = true;
friend_notifications = true;
update_interval = 30;
constructor(
argv: Pick<ArgumentsCamelCase<Arguments>, 'userNotifications' | 'friendNotifications' | 'updateInterval'>,
public storage: persist.LocalStorage,
public token: string,
public nso: ZncApi,
public data: Omit<SavedToken, 'expires_at'>,
) {
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<Presence>('/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<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
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</** NA ID */ string, (CurrentUser | Friend)[]>();
accounts = new Map</** NSA ID */ string, /** NA ID */ string>();
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(

View File

@ -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<Arguments>) {
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<Arguments>) {
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<Arguments>) {
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<ArgumentsCamelCase<Arguments>,
'userNotifications' | 'friendNotifications' | 'updateInterval' |
'friendCode' | 'showInactivePresence' | 'showEvent' | 'friendNsaid' |
'showPlayTime' | 'discordPreconnect'
>,
storage: persist.LocalStorage,
token: string,
nso: ZncApi,
data: Omit<SavedToken, 'expires_at'>,
) {
super(argv, storage, token, nso, data);
let match;
this.force_friend_code =
(match = (argv.friendCode as string)?.match(/^(SW-)?(\d{4})-?(\d{4})-?(\d{4})$/)) ?
{id: match[2] + '-' + match[3] + '-' + match[4], regenerable: false, regenerableAt: 0} : undefined;
this.show_friend_code = !!this.force_friend_code || argv.friendCode === '' || argv.friendCode === '-';
this.show_console_online = argv.showInactivePresence;
this.show_active_event = argv.showEvent;
this.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<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
this.update_presence_errors++;
if (this.update_presence_errors > 2) {
// Disconnect from Discord if the last two attempts to update presence failed
// This prevents the user's activity on Discord being stuck
if (this.rpc) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
this.title = null;
}
return super.handleError(err);
}
}
export class ZncProxyDiscordPresence extends ZncDiscordPresence {
constructor(
readonly argv: ArgumentsCamelCase<Arguments>,
public presence_url: string
) {
super(argv, null!, null!, null!, null!);
}
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);
}
}

View File

@ -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<Arguments>) {
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');
}

View File

@ -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<Arguments>) {
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');
}

View File

@ -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<Arguments>) {
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<WebServiceError>): Promise<LoopResult> {
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;
}
}
}

405
src/common/notify.ts Normal file
View File

@ -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<string, EmbeddedSplatNet2Monitor | (() => Promise<EmbeddedSplatNet2Monitor>)>();
user_notifications = true;
friend_notifications = true;
update_interval = 30;
constructor(
public storage: persist.LocalStorage,
public token: string,
public nso: ZncApi,
public data: Omit<SavedToken, 'expires_at'>,
) {
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<Presence>('/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<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
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</** NA ID */ string, (CurrentUser | Friend)[]>();
accounts = new Map</** NSA ID */ string, /** NA ID */ string>();
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;
}
}
}

285
src/common/presence.ts Normal file
View File

@ -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<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
this.update_presence_errors++;
if (this.update_presence_errors > 2) {
// Disconnect from Discord if the last two attempts to update presence failed
// This prevents the user's activity on Discord being stuck
if (this.rpc) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
this.title = null;
}
return super.handleError(err);
}
}
export class ZncProxyDiscordPresence extends ZncDiscordPresence {
constructor(
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);
}
}

View File

@ -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');
}

View File

@ -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');
}

View File

@ -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<WebServiceError>): Promise<LoopResult> {
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;
}
}
}

View File

@ -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).

View File

@ -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' : '') : '');

View File

@ -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');