Initial commit

This commit is contained in:
Samuel Elliott
2022-03-12 23:18:25 +00:00
parent fc217056e1
commit 0bdd4b4614
24 changed files with 1847 additions and 890 deletions

80
src/api/f.ts Normal file
View File

@@ -0,0 +1,80 @@
import fetch from 'node-fetch';
import createDebug from 'debug';
import { ErrorResponse } from './util.js';
const debugS2s = createDebug('api:s2s');
const debugFlapg = createDebug('api:flapg');
export async function getLoginHash(token: string, timestamp: string | number) {
debugS2s('Getting login hash');
const response = await fetch('https://elifessler.com/s2s/api/gen2', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'discord-switch-presence/1.0.0',
},
body: new URLSearchParams({
naIdToken: token,
timestamp: '' + timestamp,
}).toString(),
});
const data = await response.json() as LoginHashApiResponse | LoginHashApiError;
if ('error' in data) {
throw new ErrorResponse('[s2s] ' + data.error, response, data);
}
debugS2s('Got login hash "%s"', data.hash, data);
return data.hash;
}
export async function flapg(token: string, timestamp: string | number, guid: string, iid: FlapgIid) {
const hash = await getLoginHash(token, timestamp);
debugFlapg('Getting f parameter', {
token, timestamp, guid, iid,
});
const response = await fetch('https://flapg.com/ika2/api/login?public', {
headers: {
'x-token': token,
'x-time': '' + timestamp,
'x-guid': guid,
'x-hash': hash,
'x-ver': '3',
'x-iid': iid,
},
});
const data = await response.json() as FlapgApiResponse;
debugFlapg('Got f parameter "%s"', data.result.f);
return data.result;
}
export interface LoginHashApiResponse {
hash: string;
}
export interface LoginHashApiError {
error: string;
}
export enum FlapgIid {
/** Nintendo Switch Online app token */
NSO = 'nso',
/** Web service token */
APP = 'app',
}
export interface FlapgApiResponse {
result: {
f: string;
p1: string;
p2: string;
p3: string;
};
}

138
src/api/na.ts Normal file
View File

@@ -0,0 +1,138 @@
import fetch from 'node-fetch';
import createDebug from 'debug';
import { ErrorResponse } from './util.js';
const debug = createDebug('api:na');
export async function getNintendoAccountToken(token: string) {
debug('Getting Nintendo Account token');
const response = await fetch('https://accounts.nintendo.com/connect/1.0.0/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 8.0.0)',
},
body: JSON.stringify({
client_id: '71b963c1b7b6d119',
session_token: token,
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer-session-token',
}),
});
const nintendoAccountToken = await response.json() as NintendoAccountToken | NintendoAccountError;
if ('errorCode' in nintendoAccountToken) {
throw new ErrorResponse('[na] + ' + nintendoAccountToken.detail, response, nintendoAccountToken);
}
debug('Got Nintendo Account token', nintendoAccountToken);
return nintendoAccountToken;
}
export async function getNintendoAccountUser(token: NintendoAccountToken) {
debug('Getting Nintendo Account user info');
const response = await fetch('https://api.accounts.nintendo.com/2.0.0/users/me', {
headers: {
'Accept-Language': 'en-GB',
'User-Agent': 'NASDKAPI; Android',
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer ' + token.access_token!,
},
});
const user = await response.json() as NintendoAccountUser | NintendoAccountError;
if ('errorCode' in user) {
throw new ErrorResponse('[na] + ' + user.detail, response, user);
}
debug('Got Nintendo Account user info', user);
return user;
}
export interface NintendoAccountToken {
scope: ['openid', 'user', 'user.birthday', 'user.mii', 'user.screenName'];
token_type: 'Bearer';
id_token: string;
access_token?: string;
expires_in: 900;
}
export interface NintendoAccountUser {
emailOptedIn: boolean;
language: string;
country: string;
timezone: {
name: string;
id: string;
utcOffsetSeconds: number;
utcOffset: string;
};
region: null;
nickname: string;
clientFriendsOptedIn: boolean;
mii: {
favoriteColor: string;
id: string;
updatedAt: number;
coreData: {
'4': string;
};
clientId: '1cfe3a55ed8924d9';
imageUriTemplate: string;
storeData: {
'3': string;
};
imageOrigin: string;
etag: string;
type: 'profile';
};
isChild: boolean;
eachEmailOptedIn: {
survey: {
updatedAt: number;
optedIn: boolean;
};
deals: {
updatedAt: number;
optedIn: boolean;
};
};
updatedAt: number;
candidateMiis: unknown[];
id: string;
createdAt: number;
emailVerified: boolean;
analyticsPermissions: {
internalAnalysis: {
updatedAt: number;
permitted: boolean;
};
targetMarketing: {
updatedAt: number;
permitted: boolean;
};
};
emailOptedInUpdatedAt: number;
birthday: string;
screenName: string;
gender: string;
analyticsOptedInUpdatedAt: number;
analyticsOptedIn: boolean;
clientFriendsOptedInUpdatedAt: number;
}
export interface NintendoAccountError {
errorCode: string;
detail: string;
instance: string;
title: string;
status: number;
type: string;
}

11
src/api/util.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Response } from 'node-fetch';
export class ErrorResponse<T = unknown> extends Error {
constructor(
message: string,
readonly response: Response,
readonly data: T = undefined as any
) {
super(message);
}
}

135
src/api/znc-types.ts Normal file
View File

@@ -0,0 +1,135 @@
export interface ZncSuccessResponse<T = unknown> {
status: 0;
result: T;
correlationId: string;
}
export interface ZncErrorResponse {
status: number;
errorMessage: string;
correlationId: string;
}
export type ZncResponse<T = unknown> = ZncSuccessResponse<T> | ZncErrorResponse;
export interface AccountLogin {
user: CurrentUser;
webApiServerCredential: {
accessToken: string;
expiresIn: number;
};
firebaseCredential: {
accessToken: string;
expiresIn: number;
};
}
export interface Announcement {
announcementId: number;
priority: number;
forceDisplayEndDate: number;
distributionDate: number;
title: string;
description: string;
}
export interface Friends {
friends: Friend[];
}
export interface Friend {
id: number;
nsaId: string;
imageUri: string;
name: string;
isFriend: boolean;
isFavoriteFriend: boolean;
isServiceUser: boolean;
friendCreatedAt: number;
presence: Presence;
}
export interface Presence {
state: PresenceState;
updatedAt: number;
logoutAt: number;
game: Game | {};
}
export enum PresenceState {
/** Offline */
OFFLINE = 'OFFLINE',
/** A console linked to this account is online, but the user isn't selected in an application */
INACTIVE = 'INACTIVE',
/** The user is selected in an application */
ONLINE = 'ONLINE',
/** The user is selected in an application (and I assume playing online?) */
PLAYING = 'PLAYING',
}
export interface Game {
name: string;
imageUri: string;
shopUri: string;
totalPlayTime: number;
firstPlayedAt: number;
sysDescription: string;
}
export interface WebService {
id: number;
uri: string;
customAttributes: WebServiceAttribute[];
whiteList: string[];
name: string;
imageUri: string;
}
export interface WebServiceAttribute {
attrValue: string;
attrKey: string;
}
export interface ActiveEvent {
// ??
}
export interface CurrentUser {
id: number;
nsaId: string;
imageUri: string;
name: string;
supportId: string;
isChildRestricted: boolean;
etag: string;
links: {
nintendoAccount: {
membership: {
// active: {
// active: boolean;
// };
active: boolean;
};
};
friendCode: {
regenerable: boolean;
regenerableAt: number;
id: string;
};
};
permissions: {
presence: PresencePermissions;
};
presence: Presence;
}
export enum PresencePermissions {
FRIENDS = 'FRIENDS',
FAVORITE_FRIENDS = 'FAVORITE_FRIENDS',
SELF = 'SELF',
}
export interface WebServiceToken {
accessToken: string;
expiresIn: number;
}

164
src/api/znc.ts Normal file
View File

@@ -0,0 +1,164 @@
import fetch from 'node-fetch';
import { v4 as uuidgen } from 'uuid';
import createDebug from 'debug';
import { flapg, FlapgIid } from './f.js';
import { AccountLogin, ActiveEvent, Announcement, CurrentUser, Friends, WebService, WebServiceToken, ZncResponse } from './znc-types.js';
import { getNintendoAccountToken, getNintendoAccountUser } from './na.js';
import { ErrorResponse } from './util.js';
const debug = createDebug('api:znc');
const ZNCA_PLATFORM = 'Android';
const ZNCA_PLATFORM_VERSION = '8.0.0';
const ZNCA_VERSION = '2.0.0';
const ZNCA_USER_AGENT = `com.nintendo.znca/${ZNCA_VERSION}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`;
const ZNC_URL = 'https://api-lp1.znc.srv.nintendo.net';
export default class ZncApi {
constructor(
private token: string
) {}
protected async fetch<T = unknown>(url: string, method = 'GET', body?: string, headers?: object) {
const response = await fetch(ZNC_URL + url, {
method: method,
headers: Object.assign({
'X-Platform': ZNCA_PLATFORM,
'X-ProductVersion': ZNCA_VERSION,
'Authorization': 'Bearer ' + this.token,
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': ZNCA_USER_AGENT,
}, headers),
body: body,
});
const data = await response.json() as ZncResponse<T>;
if ('errorMessage' in data) {
throw new ErrorResponse('[znc] ' + data.errorMessage, response, data);
}
if (data.status !== 0) {
throw new ErrorResponse('[znc] Unknown error', response, data);
}
return data;
}
async getAnnouncements() {
return this.fetch<Announcement[]>('/v1/Announcement/List', 'POST', '{"parameter":{}}');
}
async getFriendList() {
return this.fetch<Friends>('/v3/Friend/List', 'POST', '{"parameter":{}}');
}
async getWebServices() {
const uuid = uuidgen();
return this.fetch<WebService[]>('/v1/Game/ListWebServices', 'POST', JSON.stringify({
requestId: uuid,
}));
}
async getActiveEvent() {
return this.fetch<ActiveEvent>('/v1/Event/GetActiveEvent', 'POST', '{"parameter":{}}');
}
async getCurrentUser() {
return this.fetch<CurrentUser>('/v3/User/ShowSelf', 'POST', '{"parameter":{}}');
}
async getWebServiceToken(id: string, nintendoAccountToken: string) {
const uuid = uuidgen();
const timestamp = '' + Math.floor(Date.now() / 1000);
const data = await flapg(nintendoAccountToken, timestamp, uuid, FlapgIid.APP);
const req = {
id,
registrationToken: nintendoAccountToken,
f: data.f,
requestId: data.p3,
timestamp: data.p2,
};
return this.fetch<WebServiceToken>('/v2/Game/GetWebServiceToken', 'POST', JSON.stringify({
parameter: req,
}));
}
static async createWithSessionToken(token: string) {
const data = await this.loginWithSessionToken(token);
return {
nso: new this(data.credential.accessToken),
data,
};
}
async renewToken(token: string) {
const data = await ZncApi.loginWithSessionToken(token);
this.token = data.credential.accessToken;
return data;
}
static async loginWithSessionToken(token: string) {
const uuid = uuidgen();
const timestamp = '' + Math.floor(Date.now() / 1000);
// Nintendo Account token
const nintendoAccountToken = await getNintendoAccountToken(token);
// Nintendo Account user data
const user = await getNintendoAccountUser(nintendoAccountToken);
const flapgdata = await flapg(nintendoAccountToken.id_token, timestamp, uuid, FlapgIid.NSO);
debug('Getting Nintendo Switch Online app token');
const response = await fetch(ZNC_URL + '/v3/Account/Login', {
method: 'POST',
headers: {
'X-Platform': ZNCA_PLATFORM,
'X-ProductVersion': ZNCA_VERSION,
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': ZNCA_USER_AGENT,
},
body: JSON.stringify({
parameter: {
naIdToken: flapgdata.p1,
naBirthday: user.birthday,
naCountry: user.country,
language: user.language,
timestamp: flapgdata.p2,
requestId: flapgdata.p3,
f: flapgdata.f,
},
}),
});
const data = await response.json() as ZncResponse<AccountLogin>;
if ('errorMessage' in data) {
throw new ErrorResponse('[znc] ' + data.errorMessage, response, data);
}
if (data.status !== 0) {
throw new ErrorResponse('[znc] Unknown error', response, data);
}
debug('Got Nintendo Switch Online app token', data);
return {
uuid,
timestamp,
nintendoAccountToken,
user,
flapg: flapgdata,
nsoAccount: data.result,
credential: data.result.webApiServerCredential,
};
}
}