mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-07-12 22:31:02 -05:00
Move monitoring classes, use nxapi debug namespace for non-app/cli modules
This commit is contained in:
parent
1efdfc9deb
commit
b531fcced4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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/';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
405
src/common/notify.ts
Normal 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
285
src/common/presence.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
158
src/common/splatnet2/dump-records.ts
Normal file
158
src/common/splatnet2/dump-records.ts
Normal 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');
|
||||
}
|
||||
192
src/common/splatnet2/dump-results.ts
Normal file
192
src/common/splatnet2/dump-results.ts
Normal 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');
|
||||
}
|
||||
109
src/common/splatnet2/monitor.ts
Normal file
109
src/common/splatnet2/monitor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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' : '') : '');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user