Initial commit

This commit is contained in:
Samuel Elliott 2022-03-12 23:18:25 +00:00
parent fc217056e1
commit 0bdd4b4614
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
24 changed files with 1847 additions and 890 deletions

View File

@ -1,5 +1,6 @@
{
"files.associations": {
"**/*/data/*": "json"
}
"**/data/*": "json"
},
"typescript.tsdk": "node_modules/typescript/lib"
}

14
README.md Normal file
View File

@ -0,0 +1,14 @@
nintendo-znc
===
Set Discord Rich Presence and get notifications for Nintendo Switch friends using the Nintendo Switch Online app API.
### Links
- Nintendo Switch Online app API docs
- https://github.com/ZekeSnider/NintendoSwitchRESTAPI
- https://dev.to/mathewthe2/intro-to-nintendo-switch-rest-api-2cm7
- splatnet2statink and flapg docs
- https://github.com/frozenpandaman/splatnet2statink/wiki/api-docs
- Disabling TLS certificate validation (entirely) with Frida on Android
- https://httptoolkit.tech/blog/frida-certificate-pinning/

194
package-lock.json generated
View File

@ -1,27 +1,32 @@
{
"name": "discord-switch-presence",
"name": "nintendo-znc",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "discord-switch-presence",
"name": "nintendo-znc",
"version": "1.0.0",
"dependencies": {
"cli-table": "^0.3.11",
"debug": "^4.3.3",
"discord-rpc": "^4.0.1",
"node-fetch": "^3.2.2",
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"uuid": "^8.3.2",
"yargs": "^17.3.1"
},
"bin": {
"nintendo-znc": "bin/nintendo-znc.js"
},
"devDependencies": {
"@types/cli-table": "^0.3.0",
"@types/debug": "^4.1.7",
"@types/discord-rpc": "^4.0.0",
"@types/node": "^17.0.21",
"@types/node-notifier": "^8.0.2",
"@types/node-persist": "^3.1.2",
"@types/read": "^0.0.29",
"@types/uuid": "^8.3.4",
@ -62,6 +67,15 @@
"integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==",
"dev": true
},
"node_modules/@types/node-notifier": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/node-notifier/-/node-notifier-8.0.2.tgz",
"integrity": "sha512-5v0PhPv0AManpxT7W25Zipmj/Lxp1WqfkcpZHyqSloB+gGoAHRBuzhrCelFKrPvNF5ki3gAcO4kxaGO2/21u8g==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node-persist": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@types/node-persist/-/node-persist-3.1.2.tgz",
@ -289,6 +303,25 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/growly": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE="
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -297,6 +330,33 @@
"node": ">=8"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -348,6 +408,19 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-notifier": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz",
"integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==",
"dependencies": {
"growly": "^1.3.0",
"is-wsl": "^2.2.0",
"semver": "^7.3.5",
"shellwords": "^0.1.1",
"uuid": "^8.3.2",
"which": "^2.0.2"
}
},
"node_modules/node-persist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/node-persist/-/node-persist-3.1.0.tgz",
@ -386,6 +459,25 @@
"node": ">=0.10.0"
}
},
"node_modules/semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shellwords": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -458,6 +550,20 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -502,6 +608,11 @@
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/yargs": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz",
@ -562,6 +673,15 @@
"integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==",
"dev": true
},
"@types/node-notifier": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/node-notifier/-/node-notifier-8.0.2.tgz",
"integrity": "sha512-5v0PhPv0AManpxT7W25Zipmj/Lxp1WqfkcpZHyqSloB+gGoAHRBuzhrCelFKrPvNF5ki3gAcO4kxaGO2/21u8g==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/node-persist": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@types/node-persist/-/node-persist-3.1.2.tgz",
@ -727,11 +847,42 @@
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"growly": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE="
},
"is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"requires": {
"is-docker": "^2.0.0"
}
},
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -763,6 +914,19 @@
"formdata-polyfill": "^4.0.10"
}
},
"node-notifier": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz",
"integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==",
"requires": {
"growly": "^1.3.0",
"is-wsl": "^2.2.0",
"semver": "^7.3.5",
"shellwords": "^0.1.1",
"uuid": "^8.3.2",
"which": "^2.0.2"
}
},
"node-persist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/node-persist/-/node-persist-3.1.0.tgz",
@ -790,6 +954,19 @@
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I="
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"requires": {
"lru-cache": "^6.0.0"
}
},
"shellwords": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="
},
"string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -843,6 +1020,14 @@
"webidl-conversions": "^3.0.0"
}
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"requires": {
"isexe": "^2.0.0"
}
},
"wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -864,6 +1049,11 @@
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"yargs": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz",

View File

@ -1,5 +1,5 @@
{
"name": "discord-switch-presence",
"name": "nintendo-znc",
"version": "1.0.0",
"description": "",
"author": "Samuel Elliott",
@ -7,21 +7,22 @@
"type": "module",
"repository": {
"type": "git",
"url": "git@gitlab.fancy.org.uk:samuel/discord-switch-presence.git"
"url": "git@gitlab.fancy.org.uk:samuel/nintendo-znc.git"
},
"bin": {
"discord-switch-presence": "bin/discord-switch-presence.js"
"nintendo-znc": "bin/nintendo-znc.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"cli": "node bin/discord-switch-presence.js",
"start": "node bin/discord-switch-presence.js presence"
"cli": "node bin/nintendo-znc.js",
"start": "node bin/nintendo-znc.js presence"
},
"dependencies": {
"cli-table": "^0.3.11",
"debug": "^4.3.3",
"discord-rpc": "^4.0.1",
"node-fetch": "^3.2.2",
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"uuid": "^8.3.2",
@ -32,6 +33,7 @@
"@types/debug": "^4.1.7",
"@types/discord-rpc": "^4.0.0",
"@types/node": "^17.0.21",
"@types/node-notifier": "^8.0.2",
"@types/node-persist": "^3.1.2",
"@types/read": "^0.0.29",
"@types/uuid": "^8.3.4",

View File

@ -1,535 +0,0 @@
import fetch from 'node-fetch';
import { v4 as uuidgen } from 'uuid';
import { stringify as buildQueryString } from 'querystring';
import createDebug from 'debug';
const debug = createDebug('api');
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('https://api-lp1.znc.srv.nintendo.net' + url, {
method: method,
body: body,
headers: Object.assign({
'X-Platform': 'Android',
'X-ProductVersion': '2.0.0',
'Authorization': 'Bearer ' + this.token,
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': 'com.nintendo.znca/2.0.0(Android/8.0.0)',
}, headers),
});
const data = await response.json() as ZncResponse<T>;
if ('errorMessage' in data) {
const error = new Error('[znc] ' + data.errorMessage);
// @ts-expect-error
error.response = response;
// @ts-expect-error
error.data = data;
throw error;
}
if (data.status !== 0) {
const error = new Error('[znc] Unknown error');
// @ts-expect-error
error.response = response;
// @ts-expect-error
error.data = data;
throw error;
}
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 flapg = await ZncApi.flapg(nintendoAccountToken, timestamp, uuid, 'app');
const req = {
id,
registrationToken: flapg.p1,
f: flapg.f,
requestId: flapg.p3,
timestamp: flapg.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
//
debug('[na] Getting Nintendo Account token');
const nintendoAccountTokenResponse = await fetch('https://accounts.nintendo.com/connect/1.0.0/api/token', {
method: 'POST',
body: JSON.stringify({
client_id: '71b963c1b7b6d119',
session_token: token,
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer-session-token',
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 8.0.0)',
},
});
const nintendoAccountToken = await nintendoAccountTokenResponse.json() as
NintendoAccountToken | NintendoAccountError;
if ('errorCode' in nintendoAccountToken) {
const error = new Error('[na] ' + nintendoAccountToken.detail);
// @ts-expect-error
error.response = nintendoAccountTokenResponse;
// @ts-expect-error
error.data = nintendoAccountToken;
throw error;
}
debug('[na] Got Nintendo Account token', nintendoAccountToken);
//
// Nintendo Account user data
//
debug('[na] Getting Nintendo Account user info');
const userResponse = 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 ' + nintendoAccountToken.access_token!,
},
});
const user = await userResponse.json() as NintendoAccountUser | NintendoAccountError;
if ('errorCode' in user) {
const error = new Error('[na] ' + user.detail);
// @ts-expect-error
error.response = userResponse;
// @ts-expect-error
error.data = user;
throw error;
}
debug('[na] Got Nintendo Account user info', user);
//
// Nintendo Switch Online app token
//
const flapg = await this.flapg(nintendoAccountToken.id_token, timestamp, uuid, 'nso');
debug('[znc] Getting Nintendo Switch Online app token');
const response = await fetch('https://api-lp1.znc.srv.nintendo.net/v3/Account/Login', {
method: 'POST',
body: JSON.stringify({
parameter: {
naIdToken: flapg.p1,
naBirthday: user.birthday,
naCountry: user.country,
language: user.language,
timestamp: flapg.p2,
requestId: flapg.p3,
f: flapg.f,
},
}),
headers: {
'X-Platform': 'Android',
'X-ProductVersion': '2.0.0',
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': 'com.nintendo.znca/2.0.0(Android/8.0.0)',
},
});
const data = await response.json() as ZncResponse<AccountLogin>;
debug('[znc] Got Nintendo Switch Online app token', data);
if ('errorMessage' in data) {
const error = new Error('[znc] ' + data.errorMessage);
// @ts-expect-error
error.response = response;
// @ts-expect-error
error.data = data;
throw error;
}
if (data.status !== 0) {
const error = new Error('[znc] Unknown error');
// @ts-expect-error
error.response = response;
// @ts-expect-error
error.data = data;
throw error;
}
return {
uuid,
timestamp,
nintendoAccountToken,
user,
flapg,
credential: data.result.webApiServerCredential,
};
}
static async getLoginHash(token: string, timestamp: string | number) {
debug('[s2s] Getting login hash');
const response = await fetch('https://elifessler.com/s2s/api/gen2', {
method: 'POST',
body: buildQueryString({
naIdToken: token,
timestamp: '' + timestamp,
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'discord-switch-presence/1.0.0',
},
});
const data = await response.json() as LoginHashApiResponse | LoginHashApiError;
if ('error' in data) {
const error = new Error('[s2s] ' + data.error);
// @ts-expect-error
error.response = response;
// @ts-expect-error
error.data = data;
throw error;
}
debug('[s2s] Got login hash "%s"', data.hash, data);
return data.hash;
}
static async flapg(token: string, timestamp: string | number, guid: string, iid: 'nso' | 'app') {
const hash = await this.getLoginHash(token, timestamp);
debug('[flapg] 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;
debug('[flapg] Got f parameter "%s"', data.result.f);
return data.result;
}
}
interface LoginHashApiResponse {
hash: string;
}
interface LoginHashApiError {
error: string;
}
export interface FlapgApiResponse {
result: {
f: string;
p1: string;
p2: string;
p3: string;
};
}
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;
}
interface NintendoAccountError {
errorCode: string;
detail: string;
instance: string;
title: string;
status: number;
type: string;
}
interface ZncSuccessResponse<T = unknown> {
status: 0;
result: T;
correlationId: string;
}
interface ZncErrorResponse {
status: number;
errorMessage: string;
correlationId: string;
}
type ZncResponse<T = unknown> = ZncSuccessResponse<T> | ZncErrorResponse;
export interface AccountLogin {
user: {
id: number;
nsaId: string;
imageUri: string;
name: string;
supportId: string;
isChildRestricted: boolean;
etag: string;
links: {
nintendoAccount: {
membership: {
active: boolean;
};
};
friendCode: {
regenerable: boolean;
regenerableAt: number;
id: string;
};
};
permissions: {
presence: string;
};
presence: Presence;
};
webApiServerCredential: {
accessToken: string;
expiresIn: number;
};
firebaseCredential: {
accessToken: string;
expiresIn: number;
};
}
interface Announcement {
announcementId: number;
priority: number;
forceDisplayEndDate: number;
distributionDate: number;
title: string;
description: string;
}
interface Friends {
friends: Friend[];
}
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',
INACTIVE = 'INACTIVE',
ONLINE = 'ONLINE',
}
export interface Game {
name: string;
imageUri: string;
shopUri: string;
totalPlayTime: number;
firstPlayedAt: number;
sysDescription: string;
}
interface WebService {
id: number;
uri: string;
customAttributes: WebServiceAttribute[];
whiteList: string[];
name: string;
imageUri: string;
}
interface WebServiceAttribute {
attrValue: string;
attrKey: string;
}
interface ActiveEvent {
// ??
}
interface CurrentUser {
id: number;
nsaId: string;
imageUri: string;
name: string;
supportId: string;
isChildRestricted: boolean;
etag: string;
links: {
nintendoAccount: {
membership: {
active: {
active: boolean;
};
};
};
friendCode: {
regenerable: boolean;
regenerableAt: number;
id: string;
};
};
permissions: {
presence: string;
};
presence: Presence;
}
interface WebServiceToken {
accessToken: string;
expiresIn: number;
}

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,
};
}
}

View File

@ -1,355 +1,26 @@
import Yargs from 'yargs';
import ZncApi, { AccountLogin, FlapgApiResponse, Game, NintendoAccountToken, NintendoAccountUser, Presence, PresenceState } from './api.js';
import persist from 'node-persist';
import * as path from 'path';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import DiscordRPC from 'discord-rpc';
import titles, { defaultTitle } from './titles.js';
import * as util from 'util';
import createDebug from 'debug';
import Yargs from 'yargs';
import { YargsArguments } from './util.js';
import * as commands from './cli/index.js';
const debug = createDebug('cli');
interface SavedToken {
uuid: string;
timestamp: string;
nintendoAccountToken: NintendoAccountToken;
user: NintendoAccountUser;
flapg: FlapgApiResponse['result'];
credential: AccountLogin['webApiServerCredential'];
expires_at: number;
}
async function initStorage(dir = path.join(import.meta.url.substr(7), '..', '..', 'data')) {
const storage = persist.create({
dir,
stringify: data => JSON.stringify(data, null, 4) + '\n',
});
await storage.init();
return storage;
}
async function getToken(storage: persist.LocalStorage, token: string) {
if (!token) {
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `discord-switch-presence token`.');
throw new Error('Invalid token');
}
const existingToken: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
debug('Authenticating to znc with session token');
const data = await ZncApi.createWithSessionToken(token);
const existingToken: SavedToken = {
...data.data,
expires_at: Date.now() + (data.data.credential.expiresIn * 1000),
};
await storage.setItem('NsoToken.' + token, existingToken);
return data;
}
debug('Using existing token');
return {
nso: new ZncApi(existingToken.credential.accessToken),
data: existingToken,
};
}
const yargs = Yargs(process.argv.slice(2));
yargs.option('data-path', {
const yargs = Yargs(process.argv.slice(2)).option('data-path', {
describe: 'Data storage path',
type: 'string',
default: path.join(import.meta.url.substr(7), '..', '..', 'data'),
});
yargs.command('token [token]', 'Set the default Nintendo Account session token', yargs => {
yargs.option('token', {
describe: 'Nintendo Account session token (it is recommended this is not set and you enter it interactively)',
type: 'string',
requiresArg: false,
});
yargs.option('auth', {
describe: 'Authenticate immediately',
type: 'boolean',
default: true,
});
}, async argv => {
export type Arguments = YargsArguments<typeof yargs>;
for (const command of Object.values(commands)) {
// @ts-expect-error
const storage = await initStorage(argv.dataPath);
let token = argv.token as string | undefined;
if (!token) {
const read = await import('read');
// @ts-expect-error
const prompt = util.promisify(read.default as typeof read);
token = await prompt({
prompt: `Token: `,
silent: true,
output: process.stderr,
});
}
await storage.setItem('SessionToken', token);
if (argv.auth) {
const {nso, data} = await getToken(storage, token);
console.log('Authenticated as Nintendo Account %s (%s)', data.user.screenName, data.user.nickname);
} else {
console.log('Saved token');
}
});
yargs.command('user', 'Get the authenticated Nintendo Account', yargs => {
yargs.option('token', {
describe: 'Nintendo Account session token',
type: 'string',
requiresArg: false,
});
}, async argv => {
// @ts-expect-error
const storage = await initStorage(argv.dataPath);
const token: string = (argv.token as string) || await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
console.log('Nintendo Account', data.user);
});
yargs.command('friends', 'List Nintendo Switch friends', yargs => {
yargs.option('token', {
describe: 'Nintendo Account session token',
type: 'string',
requiresArg: false,
});
}, async argv => {
console.log('Listing friends');
// @ts-expect-error
const storage = await initStorage(argv.dataPath);
const token: string = (argv.token as string) || await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
const announcements = await nso.getAnnouncements();
const friends = await nso.getFriendList();
const webservices = await nso.getWebServices();
const activeevent = await nso.getActiveEvent();
const table = new Table({
head: [
'ID',
'NA ID',
'Name',
'Status',
'Favourite?',
'Added at',
],
});
for (const friend of friends.result.friends) {
const hours = 'name' in friend.presence.game ? Math.floor(friend.presence.game.totalPlayTime / 60) : 0;
const minutes = 'name' in friend.presence.game ? friend.presence.game.totalPlayTime - (hours * 60) : 0;
table.push([
friend.id,
friend.nsaId,
friend.name,
friend.presence.state === PresenceState.ONLINE ?
'name' in friend.presence.game ?
'Playing ' + friend.presence.game.name +
'; played for ' + (hours || !minutes ? hours + ' hour' + (hours === 1 ? '' : 's') : '') +
(minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') +
' since ' + new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') :
'Online' :
friend.presence.logoutAt ?
'Last seen ' + new Date(friend.presence.logoutAt * 1000).toISOString() :
'Offline',
friend.isFavoriteFriend ? 'Yes' : 'No',
new Date(friend.friendCreatedAt * 1000).toISOString(),
]);
}
console.log(table.toString());
});
function getDiscordPresence(game: Game): {
id: string;
title: string | undefined;
presence: DiscordRPC.Presence;
showTimestamp?: boolean;
} {
const match = game.shopUri.match(/^https:\/\/ec\.nintendo\.com\/apps\/([0-9a-f]{16})\//);
const titleid = match?.[1];
const title = titles.find(t => t.id === titleid) || defaultTitle;
const hours = Math.floor(game.totalPlayTime / 60);
const minutes = game.totalPlayTime - (hours * 60);
const text = [];
if (title.titleName === true) text.push(game.name);
else if (title.titleName) text.push(title.titleName);
if (hours >= 1) text.push('Played for ' + hours + ' hour' + (hours === 1 ? '' : 's') +
(minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') +
' since ' + new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB'));
return {
id: title.client || defaultTitle.client,
title: titleid,
presence: {
details: text[0],
state: text[1],
largeImageKey: title.largeImageKey,
smallImageKey: title.smallImageKey,
},
showTimestamp: title.showTimestamp,
};
yargs.command(command);
}
yargs.command('presence', 'Start Discord Rich Presence', yargs => {
yargs.option('token', {
describe: 'Nintendo Account session token',
type: 'string',
requiresArg: false,
});
yargs.option('friend-naid', {
describe: 'Friend\'s Nintendo Account ID',
type: 'string',
requiresArg: false,
});
}, async argv => {
// @ts-expect-error
const storage = await initStorage(argv.dataPath);
const token: string = (argv.token as string) || await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
let rpc: {client: DiscordRPC.Client, id: string} | null = null;
let title: {id: string; since: number} | null = null;
let i = 0;
async function updatePresence(presence: Presence | null) {
console.log('Presence', i++, presence);
if (presence?.state === PresenceState.ONLINE && 'name' in presence.game) {
const discordpresence = getDiscordPresence(presence.game);
if (rpc && rpc.id !== discordpresence.id) {
await rpc?.client.destroy();
rpc = null;
}
if (!rpc) {
const client = new DiscordRPC.Client({transport: 'ipc'});
await client.connect(discordpresence.id);
rpc = {client, id: discordpresence.id};
}
if (discordpresence.title) {
if (discordpresence.title !== title?.id) {
title = {id: discordpresence.title, since: Date.now()};
}
if (discordpresence.showTimestamp) {
discordpresence.presence.startTimestamp = title.since;
}
} else {
title = null;
}
rpc.client.setActivity(discordpresence.presence);
}
if (!presence || presence.state !== PresenceState.ONLINE || !('name' in presence.game)) {
if (rpc) {
await rpc.client.destroy();
rpc = null;
}
title = null;
}
}
const announcements = await nso.getAnnouncements();
const friends = await nso.getFriendList();
const webservices = await nso.getWebServices();
const activeevent = await nso.getActiveEvent();
if (argv.friendNaid) {
const friend = friends.result.friends.find(f => f.nsaId === argv.friendNaid);
if (!friend) {
throw new Error('User "' + argv.friendNaid + '" is not friends with this user');
}
await updatePresence(friend.presence);
} else {
const user = await nso.getCurrentUser();
await updatePresence(user.result.presence);
}
await new Promise(rs => setTimeout(rs, 30000));
while (true) {
try {
if (argv.friendNaid) {
await nso.getActiveEvent();
await nso.getFriendList();
await nso.getWebServices();
const friend = friends.result.friends.find(f => f.nsaId === argv.friendNaid);
if (!friend) {
// Is the authenticated user no longer friends with this user?
await updatePresence(null);
continue;
}
await updatePresence(friend.presence);
} else {
const user = await nso.getCurrentUser();
await updatePresence(user.result.presence);
}
await new Promise(rs => setTimeout(rs, 30000));
} catch (err) {
// @ts-expect-error
if (err?.data?.status === 9404) {
// Token expired
debug('Renewing token');
const data = await nso.renewToken(token);
const existingToken: SavedToken = {
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
await storage.setItem('NsoToken.' + token, existingToken);
} else {
throw err;
}
}
}
});
yargs
.scriptName('discord-switch-presence')
.scriptName('nintendo-znc')
.demandCommand()
.help()
// .version(false)

61
src/cli/announcements.ts Normal file
View File

@ -0,0 +1,61 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
const debug = createDebug('cli:announcements');
export const command = 'announcements';
export const desc = 'List Nintendo Switch Online app announcements';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.log('Listing announcements');
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
const announcements = await nso.getAnnouncements();
const friends = await nso.getFriendList();
const webservices = await nso.getWebServices();
const activeevent = await nso.getActiveEvent();
const table = new Table({
head: [
'ID',
'Title',
'Priority',
'Date',
'Display end date',
],
});
for (const announcement of announcements.result) {
table.push([
announcement.announcementId,
announcement.title.substr(0, 60),
announcement.priority,
new Date(announcement.distributionDate * 1000).toISOString(),
new Date(announcement.forceDisplayEndDate * 1000).toISOString(),
]);
}
console.log(table.toString());
}

78
src/cli/friends.ts Normal file
View File

@ -0,0 +1,78 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import { PresenceState } from '../api/znc-types.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
const debug = createDebug('cli:friends');
export const command = 'friends';
export const desc = 'List Nintendo Switch friends';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.log('Listing friends');
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
const announcements = await nso.getAnnouncements();
const friends = await nso.getFriendList();
const webservices = await nso.getWebServices();
const activeevent = await nso.getActiveEvent();
const table = new Table({
head: [
'ID',
'NA ID',
'Name',
'Status',
'Favourite?',
'Added at',
],
});
for (const friend of friends.result.friends) {
const online = friend.presence.state === PresenceState.ONLINE ||
friend.presence.state === PresenceState.PLAYING;
const hours = 'name' in friend.presence.game ? Math.floor(friend.presence.game.totalPlayTime / 60) : 0;
const minutes = 'name' in friend.presence.game ? friend.presence.game.totalPlayTime - (hours * 60) : 0;
table.push([
friend.id,
friend.nsaId,
friend.name,
online ?
'name' in friend.presence.game ?
'Playing ' + friend.presence.game.name +
'; played for ' + (hours || !minutes ? hours + ' hour' + (hours === 1 ? '' : 's') : '') +
(minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') +
' since ' + new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') :
'Online' :
friend.presence.logoutAt ?
'Last seen ' + new Date(friend.presence.logoutAt * 1000).toISOString() :
'Offline',
friend.isFavoriteFriend ? 'Yes' : 'No',
new Date(friend.friendCreatedAt * 1000).toISOString(),
]);
}
console.log(table.toString());
}

9
src/cli/index.ts Normal file
View File

@ -0,0 +1,9 @@
export * as token from './token.js';
export * as users from './users.js';
export * as user from './user.js';
export * as announcements from './announcements.js';
export * as webservices from './webservices.js';
export * as webservicetoken from './webservicetoken.js';
export * as friends from './friends.js';
export * as presence from './presence.js';
export * as notify from './notify.js';

220
src/cli/notify.ts Normal file
View File

@ -0,0 +1,220 @@
import createDebug from 'debug';
import persist from 'node-persist';
import notifier from 'node-notifier';
import { CurrentUser, Friend, Game, PresenceState } from '../api/znc-types.js';
import ZncApi from '../api/znc.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getTitleIdFromEcUrl, getToken, initStorage, SavedToken, YargsArguments } from '../util.js';
const debug = createDebug('cli:notify');
const debugFriends = createDebug('cli:notify:friends');
export const command = 'notify';
export const desc = 'Show notifications when friends come online without starting Discord Rich Presence';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('user-notifications', {
describe: 'Show notification for your own user',
type: 'boolean',
default: false,
}).option('friend-notifications', {
describe: 'Show notification for friends',
type: 'boolean',
default: true,
}).option('update-interval', {
describe: 'Update interval in seconds',
type: 'number',
default: 30,
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
if (!argv.userNotifications && !argv.friendNotifications) {
throw new Error('Must enable either user or friend notifications');
}
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
const i = new ZncNotifications(argv, storage, token, nso, data);
console.log('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
await i.init();
while (true) {
await i.loop();
}
}
export class ZncNotifications {
constructor(
readonly argv: ArgumentsCamelCase<Arguments>,
public storage: persist.LocalStorage,
public token: string,
public nso: ZncApi,
public data: Omit<SavedToken, 'expires_at'>,
) {}
async init() {
const announcements = await this.nso.getAnnouncements();
const friends = await this.nso.getFriendList();
const webservices = await this.nso.getWebServices();
const activeevent = await this.nso.getActiveEvent();
if (this.argv.userNotifications) {
const user = await this.nso.getCurrentUser();
await this.updateFriendsStatusForNotifications(this.argv.friendNotifications ?
[user.result, ...friends.result.friends] : [user.result]);
} else {
await this.updateFriendsStatusForNotifications(friends.result.friends);
}
await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000));
}
onlinefriends: (CurrentUser | Friend)[] = [];
async updateFriendsStatusForNotifications(friends: (CurrentUser | Friend)[], initialRun?: boolean) {
const newonlinefriends: (CurrentUser | Friend)[] = [];
for (const friend of friends) {
const lastpresence = this.onlinefriends.find(f => f.id === friend.id)?.presence;
const online = friend.presence.state === PresenceState.ONLINE ||
friend.presence.state === PresenceState.PLAYING;
if (!lastpresence && online) {
// Friend has come online
const currenttitle = friend.presence.game as Game;
debugFriends('%s is now online, title %s %s', friend.name,
currenttitle.name, JSON.stringify(currenttitle.sysDescription));
notifier.notify({
title: friend.name,
message: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: currenttitle.imageUri,
});
newonlinefriends.push(friend);
} else if (lastpresence && !online) {
// Friend has gone offline
const lasttitle = lastpresence.game as Game;
notifier.notify({
title: friend.name,
message: 'Offline',
});
debugFriends('%s is now offline, was playing title %s %s', friend.name,
lasttitle.name, JSON.stringify(lasttitle.sysDescription));
} else if (lastpresence && 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 is now playing %s %s, was playing %s %s',
friend.name,
currenttitle.name, JSON.stringify(currenttitle.sysDescription),
lasttitle.name, JSON.stringify(lasttitle.sysDescription));
notifier.notify({
title: friend.name,
message: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: currenttitle.imageUri,
});
} 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));
notifier.notify({
title: friend.name,
message: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: currenttitle.imageUri,
});
}
newonlinefriends.push(friend);
}
}
this.onlinefriends = newonlinefriends;
}
async update() {
debug('Updating presence');
if (this.argv.friendNotifications) {
const activeevent = await this.nso.getActiveEvent();
const friends = await this.nso.getFriendList();
const webservices = await this.nso.getWebServices();
if (this.argv.userNotifications) {
const user = await this.nso.getCurrentUser();
await this.updateFriendsStatusForNotifications([user.result, ...friends.result.friends]);
} else {
await this.updateFriendsStatusForNotifications(friends.result.friends);
}
} else {
const user = await this.nso.getCurrentUser();
await this.updateFriendsStatusForNotifications([user.result]);
}
debug('Updated presence');
}
async loop() {
try {
await this.update();
await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000));
} catch (err) {
// @ts-expect-error
if (err?.data?.status === 9404) {
// Token expired
debug('Renewing token');
const data = await this.nso.renewToken(this.token);
const existingToken: SavedToken = {
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
await this.storage.setItem('NsoToken.' + this.token, existingToken);
} else {
throw err;
}
}
}
}

277
src/cli/presence.ts Normal file
View File

@ -0,0 +1,277 @@
import createDebug from 'debug';
import persist from 'node-persist';
import DiscordRPC from 'discord-rpc';
import { CurrentUser, Presence, PresenceState } from '../api/znc-types.js';
import ZncApi from '../api/znc.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getDiscordPresence, getTitleIdFromEcUrl, getToken, initStorage, SavedToken, YargsArguments } from '../util.js';
import { ZncNotifications } from './notify.js';
const debug = createDebug('cli:presence');
const debugFriends = createDebug('cli:presence:friends');
const debugDiscord = createDebug('cli:presence:discordrpc');
export const command = 'presence';
export const desc = 'Start Discord Rich Presence';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('friend-naid', {
describe: 'Friend\'s Nintendo Account ID',
type: 'string',
}).option('friend-code', {
describe: 'Friend code',
type: 'string',
}).option('user-notifications', {
describe: 'Show notification for your own user',
type: 'boolean',
default: false,
}).option('friend-notifications', {
describe: 'Show notification for friends',
type: 'boolean',
default: false,
}).option('update-interval', {
describe: 'Update interval in seconds',
type: 'number',
default: 30,
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
const i = new ZncDiscordPresence(argv, storage, token, nso, data);
console.log('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
await i.init();
while (true) {
await i.loop();
}
}
class ZncDiscordPresence extends ZncNotifications {
forceFriendCode: CurrentUser['links']['friendCode'] | undefined;
constructor(
readonly argv: ArgumentsCamelCase<Arguments>,
storage: persist.LocalStorage,
token: string,
nso: ZncApi,
data: Omit<SavedToken, 'expires_at'>,
) {
super(argv, storage, token, nso, data);
let match;
this.forceFriendCode =
(match = (this.argv.friendCode as string)?.match(/^(SW-)?(\d{4})-?(\d{4})-?(\d{4})$/)) ?
{id: match[2] + '-' + match[3] + '-' + match[4], regenerable: false, regenerableAt: 0} : undefined;
}
async init() {
const announcements = await this.nso.getAnnouncements();
const friends = await this.nso.getFriendList();
const webservices = await this.nso.getWebServices();
const activeevent = await this.nso.getActiveEvent();
if (this.argv.friendNaid) {
const friend = friends.result.friends.find(f => f.nsaId === this.argv.friendNaid);
if (!friend) {
throw new Error('User "' + this.argv.friendNaid + '" is not friends with this user');
}
if (this.argv.userNotifications && this.argv.friendNotifications) {
await this.updateFriendsStatusForNotifications(friends.result.friends, true);
} else if (this.argv.friendNotifications) {
await this.updateFriendsStatusForNotifications(
friends.result.friends.filter(f => f.nsaId !== this.argv.friendNaid), true);
} else if (this.argv.userNotifications && friend) {
await this.updateFriendsStatusForNotifications([friend], true);
}
await this.updatePresence(friend.presence);
} else {
const user = await this.nso.getCurrentUser();
if (this.argv.friendNotifications) {
await this.updateFriendsStatusForNotifications(this.argv.userNotifications ?
[user.result, ...friends.result.friends] : friends.result.friends);
} else if (this.argv.userNotifications) {
await this.updateFriendsStatusForNotifications([user.result]);
}
await this.updatePresence(user.result.presence, user.result.links.friendCode);
}
await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000));
}
rpc: {client: DiscordRPC.Client, id: string} | null = null;
title: {id: string; since: number} | null = null;
i = 0;
async updatePresence(presence: Presence | null, friendcode?: CurrentUser['links']['friendCode']) {
debug('Presence %d state=%s, updatedAt=%s, logoutAt=%s', this.i++,
presence?.state,
new Date((presence?.updatedAt ?? 0) * 1000).toString(),
new Date((presence?.logoutAt ?? 0) * 1000).toString());
if (presence && 'name' in presence.game) {
debug('Title %s, id=%s, totalPlayTime=%d, firstPlayedAt=%s, sysDescription=%s',
presence.game.name,
getTitleIdFromEcUrl(presence.game.shopUri),
presence.game.totalPlayTime,
new Date((presence.game.firstPlayedAt ?? 0) * 1000).toString(),
JSON.stringify(presence.game.sysDescription));
}
const online = presence?.state === PresenceState.ONLINE || presence?.state === PresenceState.PLAYING;
if (online && 'name' in presence.game) {
const discordpresence = getDiscordPresence(presence.game,
this.argv.friendCode === '' || this.argv.friendCode === '-' ? friendcode : this.forceFriendCode);
if (this.rpc && this.rpc.id !== discordpresence.id) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
if (!this.rpc) {
const client = new DiscordRPC.Client({transport: 'ipc'});
let attempts = 0;
let connected = false;
while (attempts < 10) {
if (attempts === 0) debugDiscord('RPC connecting');
else debugDiscord('RPC connecting, attempt %d', attempts + 1);
try {
await client.connect(discordpresence.id);
debugDiscord('RPC connected');
connected = true;
break;
} catch (err) {}
attempts++;
await new Promise(rs => setTimeout(rs, 5000));
}
if (!connected) throw new Error('Failed to connect to Discord');
// @ts-expect-error
client.transport.on('close', async () => {
if (this.rpc?.client !== client) return;
console.warn('[discordrpc] RPC client disconnected, attempting to reconnect');
debugDiscord('RPC client disconnected');
let attempts = 0;
let connected = false;
while (attempts < 10) {
if (this.rpc?.client !== client) return;
debugDiscord('RPC reconnecting, attempt %d', attempts + 1);
try {
await client.connect(discordpresence.id);
console.warn('[discordrpc] RPC reconnected');
debugDiscord('RPC reconnected');
connected = true;
break;
} catch (err) {}
attempts++;
await new Promise(rs => setTimeout(rs, 5000));
}
if (!connected) throw new Error('Failed to reconnect to Discord');
throw new Error('Discord disconnected');
});
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.presence.startTimestamp = this.title.since;
}
} else {
this.title = null;
}
this.rpc.client.setActivity(discordpresence.presence);
}
if (!presence || !online || !('name' in presence.game)) {
if (this.rpc) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
this.title = null;
}
}
async update() {
if (this.argv.friendNaid) {
const activeevent = await this.nso.getActiveEvent();
const friends = await this.nso.getFriendList();
const webservices = await this.nso.getWebServices();
const friend = friends.result.friends.find(f => f.nsaId === this.argv.friendNaid);
if (this.argv.userNotifications && this.argv.friendNotifications) {
await this.updateFriendsStatusForNotifications(friends.result.friends);
} else if (this.argv.friendNotifications) {
await this.updateFriendsStatusForNotifications(
friends.result.friends.filter(f => f.nsaId !== this.argv.friendNaid));
} else if (this.argv.userNotifications && friend) {
await this.updateFriendsStatusForNotifications([friend]);
}
if (!friend) {
// Is the authenticated user no longer friends with this user?
await this.updatePresence(null);
return;
}
await this.updatePresence(friend.presence);
} else {
const user = await this.nso.getCurrentUser();
if (this.argv.friendNotifications) {
const activeevent = await this.nso.getActiveEvent();
const friends = await this.nso.getFriendList();
const webservices = await this.nso.getWebServices();
await this.updateFriendsStatusForNotifications(this.argv.userNotifications ?
[user.result, ...friends.result.friends] : friends.result.friends);
} else if (this.argv.userNotifications) {
await this.updateFriendsStatusForNotifications([user.result]);
}
await this.updatePresence(user.result.presence, user.result.links.friendCode);
}
}
}

64
src/cli/token.ts Normal file
View File

@ -0,0 +1,64 @@
import * as util from 'util';
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
const debug = createDebug('cli:token');
export const command = 'token [token]';
export const desc = 'Set the default Nintendo Account session token';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('token', {
describe: 'Nintendo Account session token (it is recommended this is not set and you enter it interactively)',
type: 'string',
}).option('auth', {
describe: 'Authenticate immediately',
type: 'boolean',
default: true,
}).option('select', {
describe: 'Set as default user (default: true if only user)',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
if (!argv.token) {
const read = await import('read');
// @ts-expect-error
const prompt = util.promisify(read.default as typeof read);
argv.token = await prompt({
prompt: `Token: `,
silent: true,
output: process.stderr,
});
}
await storage.setItem('SessionToken', argv.token);
if (argv.auth) {
const {nso, data} = await getToken(storage, argv.token);
console.log('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
await storage.setItem('NintendoAccountToken.' + data.user.id, argv.token);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
if ('select' in argv ? argv.select : users.size === 1) {
await storage.setItem('SelectedUser', data.user.id);
console.log('Set as default user');
}
} else {
console.log('Saved token');
}
}

44
src/cli/user.ts Normal file
View File

@ -0,0 +1,44 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
const debug = createDebug('cli:user');
export const command = 'user';
export const desc = 'Get the authenticated Nintendo Account';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('force-refresh', {
describe: 'Always fetch Nintendo Switch user data (not including Nintendo Account user data)',
type: 'boolean',
default: false,
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
if (argv.forceRefresh && 'expires_at' in data) {
const user = await nso.getCurrentUser();
console.log('Nintendo Account', data.user);
console.log('Nintendo Switch user', user.result);
} else {
console.log('Nintendo Account', data.user);
console.log('Nintendo Switch user', data.nsoAccount.user);
}
}

95
src/cli/users.ts Normal file
View File

@ -0,0 +1,95 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { Argv, initStorage, SavedToken } from '../util.js';
const debug = createDebug('cli:users');
export const command = 'users <command>';
export const desc = 'Manage authenticated Nintendo Accounts';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.command('list', 'Lists known Nintendo Accounts', () => {}, async argv => {
const storage = await initStorage(argv.dataPath);
const users: string[] | undefined = await storage.getItem('NintendoAccountIds');
const selected: string | undefined = await storage.getItem('SelectedUser');
const table = new Table({
head: [
'ID',
'Screen name',
'Nickname',
'Country',
'Nintendo Switch username',
],
});
for (const userid of users ?? []) {
const token: string | undefined = await storage.getItem('NintendoAccountToken.' + userid);
if (!token) continue;
const cache: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
if (!cache) continue;
table.push([
cache.user.id + (selected === cache.user.id ? ' *' : ''),
cache.user.screenName,
cache.user.nickname,
cache.user.country,
cache.nsoAccount.user.name,
]);
}
if (!table.length) {
console.log('No Nintendo Accounts');
return;
}
console.log(table.toString());
}).command('set <user>', 'Sets the default Nintendo Account', yargs => {
return yargs.positional('user', {
describe: 'Nintendo Account ID',
type: 'string',
demandOption: true,
});
}, async argv => {
const storage = await initStorage(argv.dataPath);
const token: string | undefined = await storage.getItem('NintendoAccountToken.' + argv.user);
if (!token) {
console.error('No session token for this user. Set a Nintendo Account session token by running `nintendo-znc token --select`.');
throw new Error('Unknown user');
}
await storage.setItem('SelectedUser', argv.user);
}).command('forget <user>', 'Removes all data for a Nintendo Account', yargs => {
return yargs.positional('user', {
describe: 'Nintendo Account ID',
type: 'string',
demandOption: true,
});
}, async argv => {
const storage = await initStorage(argv.dataPath);
const selected: string | undefined = await storage.getItem('SelectedUser');
const token: string | undefined = await storage.getItem('NintendoAccountToken.' + argv.user);
if (!token) {
throw new Error('Unknown user');
}
if (selected === argv.user) {
await storage.removeItem('SelectedUser');
await storage.removeItem('SessionToken');
}
await storage.removeItem('NintendoAccountToken.' + argv.user);
await storage.removeItem('NsoToken.' + token);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.delete(argv.user);
await storage.setItem('NintendoAccountIds', [...users]);
});
}

57
src/cli/webservices.ts Normal file
View File

@ -0,0 +1,57 @@
import createDebug from 'debug';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
const debug = createDebug('cli:announcements');
export const command = 'webservices';
export const desc = 'List Nintendo Switch Online web services';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.log('Listing web services');
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
const announcements = await nso.getAnnouncements();
const friends = await nso.getFriendList();
const webservices = await nso.getWebServices();
const activeevent = await nso.getActiveEvent();
const table = new Table({
head: [
'ID',
'Name',
'URL',
],
});
for (const webservice of webservices.result) {
table.push([
webservice.id,
webservice.name,
webservice.uri,
]);
}
console.log(table.toString());
}

View File

@ -0,0 +1,61 @@
import createDebug from 'debug';
import fetch from 'node-fetch';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js';
const debug = createDebug('cli:announcements');
export const command = 'webservicetoken <id>';
export const desc = 'Get a token for a web service';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('id', {
describe: 'Web service ID',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
await storage.getItem('SessionToken');
const {nso, data} = await getToken(storage, token);
const announcements = await nso.getAnnouncements();
const friends = await nso.getFriendList();
const webservices = await nso.getWebServices();
const activeevent = await nso.getActiveEvent();
const webservice = webservices.result.find(w => '' + w.id === argv.id);
if (!webservice) {
throw new Error('Invalid web service');
}
const webserviceToken = await nso.getWebServiceToken(argv.id, data.credential.accessToken);
// https://app.splatoon2.nintendo.net/?lang=en-GB&na_country=GB&na_lang=en-GB
const url = new URL(webservice.uri);
url.search = new URLSearchParams({
lang: data.user.language,
na_country: data.user.country,
na_lang: data.user.language,
}).toString();
console.log('Web service', {
name: webservice.name,
url: url.toString(),
}, webserviceToken.result);
}

View File

@ -1,11 +1,4 @@
interface Title {
id: string;
client: string;
titleName?: string | true;
largeImageKey?: string;
smallImageKey?: string;
showTimestamp?: boolean;
}
import { Title } from './util.js';
export const defaultTitle: Title = {
id: '0000000000000000',

127
src/util.ts Normal file
View File

@ -0,0 +1,127 @@
import * as yargs from 'yargs';
import * as yargstypes from '../node_modules/@types/yargs/index.js';
import createDebug from 'debug';
import DiscordRPC from 'discord-rpc';
import persist from 'node-persist';
import * as path from 'path';
import { FlapgApiResponse } from './api/f.js';
import { NintendoAccountToken, NintendoAccountUser } from './api/na.js';
import { AccountLogin, CurrentUser, Game } from './api/znc-types.js';
import ZncApi from './api/znc.js';
import titles, { defaultTitle } from './titles.js';
const debug = createDebug('cli');
export type YargsArguments<T extends yargs.Argv> = T extends yargs.Argv<infer R> ? R : any;
export type Argv<T = {}> = yargs.Argv<T>;
export type ArgumentsCamelCase<T = {}> = yargstypes.ArgumentsCamelCase<T>;
export interface SavedToken {
uuid: string;
timestamp: string;
nintendoAccountToken: NintendoAccountToken;
user: NintendoAccountUser;
flapg: FlapgApiResponse['result'];
nsoAccount: AccountLogin;
credential: AccountLogin['webApiServerCredential'];
expires_at: number;
}
export async function initStorage(dir = path.join(import.meta.url.substr(7), '..', '..', 'data')) {
const storage = persist.create({
dir,
stringify: data => JSON.stringify(data, null, 4) + '\n',
});
await storage.init();
return storage;
}
export async function getToken(storage: persist.LocalStorage, token: string) {
if (!token) {
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nintendo-znc token`.');
throw new Error('Invalid token');
}
const existingToken: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
console.log('Authenticating to Nintendo Switch Online app');
debug('Authenticating to znc with session token');
const data = await ZncApi.createWithSessionToken(token);
const existingToken: SavedToken = {
...data.data,
expires_at: Date.now() + (data.data.credential.expiresIn * 1000),
};
await storage.setItem('NsoToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken.' + data.data.user.id, token);
return data;
}
debug('Using existing token');
return {
nso: new ZncApi(existingToken.credential.accessToken),
data: existingToken,
};
}
export function getTitleIdFromEcUrl(url: string) {
const match = url.match(/^https:\/\/ec\.nintendo\.com\/apps\/([0-9a-f]{16})\//);
return match?.[1] ?? null;
}
export function getDiscordPresence(game: Game, friendcode?: CurrentUser['links']['friendCode']): {
id: string;
title: string | null;
presence: DiscordRPC.Presence;
showTimestamp?: boolean;
} {
const titleid = getTitleIdFromEcUrl(game.shopUri);
const title = titles.find(t => t.id === titleid) || defaultTitle;
const hours = Math.floor(game.totalPlayTime / 60);
const minutes = game.totalPlayTime - (hours * 60);
const text = [];
if (title.titleName === true) text.push(game.name);
else if (title.titleName) text.push(title.titleName);
if (game.sysDescription) text.push(game.sysDescription);
if (hours >= 1) text.push('Played for ' + hours + ' hour' + (hours === 1 ? '' : 's') +
(minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') +
' since ' + new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB'));
if (friendcode && !title.largeImageKey) text.push('SW-' + friendcode.id);
return {
id: title.client || defaultTitle.client,
title: titleid,
presence: {
details: text[0],
state: text[1],
largeImageKey: title.largeImageKey,
largeImageText: friendcode && title.largeImageKey ? 'SW-' + friendcode.id : undefined,
smallImageKey: title.smallImageKey,
},
showTimestamp: title.showTimestamp,
};
}
export interface Title {
/** Lowercase hexadecimal title ID */
id: string;
/** Discord client ID */
client: string;
titleName?: string | true;
largeImageKey?: string;
smallImageKey?: string;
showTimestamp?: boolean;
}