nxapi/src/api/moon.ts

177 lines
6.3 KiB
TypeScript

import fetch, { Response } from 'node-fetch';
import createDebug from 'debug';
import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountToken, NintendoAccountUser } from './na.js';
import { defineResponse, ErrorResponse, HasResponse } from './util.js';
import { DailySummaries, Devices, MonthlySummaries, MonthlySummary, MoonError, ParentalControlSettingState, SmartDevices, User } from './moon-types.js';
import { timeoutSignal } from '../util/misc.js';
const debug = createDebug('nxapi:api:moon');
const MOON_URL = 'https://api-lp1.pctl.srv.nintendo.net/moon';
export const ZNMA_CLIENT_ID = '54789befb391a838';
const ZNMA_VERSION = '1.17.0';
const ZNMA_BUILD = '261';
const ZNMA_USER_AGENT = 'moon_ANDROID/' + ZNMA_VERSION + ' (com.nintendo.znma; build:' + ZNMA_BUILD +
'; ANDROID 26)';
export default class MoonApi {
onTokenExpired: ((data: MoonError, res: Response) => Promise<MoonAuthData | PartialMoonAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
protected constructor(
public token: string,
public naId: string,
readonly znma_version = ZNMA_VERSION,
readonly znma_build = ZNMA_BUILD,
readonly znma_useragent = ZNMA_USER_AGENT,
) {}
async fetch<T extends object>(
url: string, method = 'GET', body?: string, headers?: object,
/** @internal */ _autoRenewToken = true,
/** @internal */ _attempt = 0
): Promise<HasResponse<T, Response>> {
if (this._renewToken && _autoRenewToken) {
await this._renewToken;
}
const [signal, cancel] = timeoutSignal();
const response = await fetch(MOON_URL + url, {
method,
headers: Object.assign({
'Authorization': 'Bearer ' + this.token,
'Cache-Control': 'no-store',
'Content-Type': 'application/json; charset=utf-8',
'X-Moon-App-Id': 'com.nintendo.znma',
'X-Moon-Os': 'ANDROID',
'X-Moon-Os-Version': '26',
'X-Moon-Model': '',
'X-Moon-TimeZone': 'Europe/London',
'X-Moon-Os-Language': 'en-GB',
'X-Moon-App-Language': 'en-GB',
'X-Moon-App-Display-Version': this.znma_version,
'X-Moon-App-Internal-Version': this.znma_build,
'User-Agent': this.znma_useragent,
}, headers),
body,
signal,
}).finally(cancel);
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status === 401 && _autoRenewToken && !_attempt && this.onTokenExpired) {
const data = await response.json() as MoonError;
// _renewToken will be awaited when calling fetch
this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, data, response).then(data => {
if (data) this.setTokenWithSavedToken(data);
}).finally(() => {
this._renewToken = null;
});
return this.fetch(url, method, body, headers, _autoRenewToken, _attempt + 1);
}
if (response.status !== 200) {
throw new ErrorResponse('[moon] Non-200 status code', response, await response.text());
}
const data = await response.json() as T | MoonError;
if ('errorCode' in data) {
throw new ErrorResponse('[moon] ' + data.title, response, data);
}
return defineResponse(data, response);
}
async getUser() {
return this.fetch<User>('/v1/users/' + this.naId);
}
async getSmartDevices() {
return this.fetch<SmartDevices>('/v1/users/' + this.naId + '/smart_devices');
}
async getDevices() {
return this.fetch<Devices>('/v1/users/' + this.naId + '/devices');
}
async getDailySummaries(id: string) {
return this.fetch<DailySummaries>('/v1/devices/' + id + '/daily_summaries');
}
async getMonthlySummaries(id: string) {
return this.fetch<MonthlySummaries>('/v1/devices/' + id + '/monthly_summaries');
}
async getMonthlySummary(id: string, month: string) {
return this.fetch<MonthlySummary>('/v1/devices/' + id + '/monthly_summaries/' + month);
}
async getParentalControlSettingState(id: string) {
return this.fetch<ParentalControlSettingState>('/v1/devices/' + id + '/parental_control_setting_state');
}
async renewToken(token: string) {
const data = await MoonApi.loginWithSessionToken(token);
this.setTokenWithSavedToken(data);
return data;
}
private setTokenWithSavedToken(data: MoonAuthData | PartialMoonAuthData) {
this.token = data.nintendoAccountToken.access_token!;
if ('user' in data) this.naId = data.user.id;
}
static async createWithSessionToken(token: string) {
const data = await this.loginWithSessionToken(token);
return {moon: this.createWithSavedToken(data), data};
}
static createWithSavedToken(data: MoonAuthData) {
return new this(
data.nintendoAccountToken.access_token!,
data.user.id,
data.znma_version,
data.znma_build,
data.znma_useragent,
);
}
static async loginWithSessionToken(token: string): Promise<MoonAuthData> {
const { default: { moon: config } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents Moon authentication');
const znma_useragent = 'moon_ANDROID/' + config.znma_version +
' (com.nintendo.znma; build:' + config.znma_build + '; ANDROID 26)';
// Nintendo Account token
const nintendoAccountToken = await getNintendoAccountToken(token, ZNMA_CLIENT_ID);
// Nintendo Account user data
const user = await getNintendoAccountUser(nintendoAccountToken);
return {
nintendoAccountToken,
user,
znma_version: config.znma_version,
znma_build: config.znma_build,
znma_useragent: znma_useragent,
};
}
}
export interface MoonAuthData {
nintendoAccountToken: NintendoAccountToken;
user: NintendoAccountUser;
znma_version: string;
znma_build: string;
znma_useragent: string;
}
export interface PartialMoonAuthData {
nintendoAccountToken: NintendoAccountToken;
}