diff --git a/.vscode/settings.json b/.vscode/settings.json index ad9703b..cbcb31d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "files.associations": { - "**/*/data/*": "json" - } + "**/data/*": "json" + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddcf9e5 --- /dev/null +++ b/README.md @@ -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/ diff --git a/bin/discord-switch-presence.js b/bin/nintendo-znc.js similarity index 100% rename from bin/discord-switch-presence.js rename to bin/nintendo-znc.js diff --git a/package-lock.json b/package-lock.json index be704f6..412ae08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f73f765..d041801 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index 0362c2d..0000000 --- a/src/api.ts +++ /dev/null @@ -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(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; - - 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('/v1/Announcement/List', 'POST', '{"parameter":{}}'); - } - - async getFriendList() { - return this.fetch('/v3/Friend/List', 'POST', '{"parameter":{}}'); - } - - async getWebServices() { - const uuid = uuidgen(); - - return this.fetch('/v1/Game/ListWebServices', 'POST', JSON.stringify({ - requestId: uuid, - })); - } - - async getActiveEvent() { - return this.fetch('/v1/Event/GetActiveEvent', 'POST', '{"parameter":{}}'); - } - - async getCurrentUser() { - return this.fetch('/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('/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; - - 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 { - status: 0; - result: T; - correlationId: string; -} - -interface ZncErrorResponse { - status: number; - errorMessage: string; - correlationId: string; -} - -type ZncResponse = ZncSuccessResponse | 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; -} diff --git a/src/api/f.ts b/src/api/f.ts new file mode 100644 index 0000000..b12f405 --- /dev/null +++ b/src/api/f.ts @@ -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; + }; +} diff --git a/src/api/na.ts b/src/api/na.ts new file mode 100644 index 0000000..d081c61 --- /dev/null +++ b/src/api/na.ts @@ -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; +} diff --git a/src/api/util.ts b/src/api/util.ts new file mode 100644 index 0000000..664fe17 --- /dev/null +++ b/src/api/util.ts @@ -0,0 +1,11 @@ +import { Response } from 'node-fetch'; + +export class ErrorResponse extends Error { + constructor( + message: string, + readonly response: Response, + readonly data: T = undefined as any + ) { + super(message); + } +} diff --git a/src/api/znc-types.ts b/src/api/znc-types.ts new file mode 100644 index 0000000..ecad95a --- /dev/null +++ b/src/api/znc-types.ts @@ -0,0 +1,135 @@ + +export interface ZncSuccessResponse { + status: 0; + result: T; + correlationId: string; +} + +export interface ZncErrorResponse { + status: number; + errorMessage: string; + correlationId: string; +} + +export type ZncResponse = ZncSuccessResponse | 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; +} diff --git a/src/api/znc.ts b/src/api/znc.ts new file mode 100644 index 0000000..402b6c1 --- /dev/null +++ b/src/api/znc.ts @@ -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(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; + + 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('/v1/Announcement/List', 'POST', '{"parameter":{}}'); + } + + async getFriendList() { + return this.fetch('/v3/Friend/List', 'POST', '{"parameter":{}}'); + } + + async getWebServices() { + const uuid = uuidgen(); + + return this.fetch('/v1/Game/ListWebServices', 'POST', JSON.stringify({ + requestId: uuid, + })); + } + + async getActiveEvent() { + return this.fetch('/v1/Event/GetActiveEvent', 'POST', '{"parameter":{}}'); + } + + async getCurrentUser() { + return this.fetch('/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('/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; + + 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, + }; + } +} diff --git a/src/cli.ts b/src/cli.ts index 8c6429c..4f56074 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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; + +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) diff --git a/src/cli/announcements.ts b/src/cli/announcements.ts new file mode 100644 index 0000000..925d3a6 --- /dev/null +++ b/src/cli/announcements.ts @@ -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) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + 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()); +} diff --git a/src/cli/friends.ts b/src/cli/friends.ts new file mode 100644 index 0000000..ed15981 --- /dev/null +++ b/src/cli/friends.ts @@ -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) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + 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()); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..3e97361 --- /dev/null +++ b/src/cli/index.ts @@ -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'; diff --git a/src/cli/notify.ts b/src/cli/notify.ts new file mode 100644 index 0000000..08f333f --- /dev/null +++ b/src/cli/notify.ts @@ -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) { + 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>; + +export async function handler(argv: ArgumentsCamelCase) { + 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, + public storage: persist.LocalStorage, + public token: string, + public nso: ZncApi, + public data: Omit, + ) {} + + 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; + } + } + } +} diff --git a/src/cli/presence.ts b/src/cli/presence.ts new file mode 100644 index 0000000..326ba3a --- /dev/null +++ b/src/cli/presence.ts @@ -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) { + 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>; + +export async function handler(argv: ArgumentsCamelCase) { + 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, + storage: persist.LocalStorage, + token: string, + nso: ZncApi, + data: Omit, + ) { + 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); + } + } +} diff --git a/src/cli/token.ts b/src/cli/token.ts new file mode 100644 index 0000000..b415824 --- /dev/null +++ b/src/cli/token.ts @@ -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) { + 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>; + +export async function handler(argv: ArgumentsCamelCase) { + 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'); + } +} diff --git a/src/cli/user.ts b/src/cli/user.ts new file mode 100644 index 0000000..b21714e --- /dev/null +++ b/src/cli/user.ts @@ -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) { + 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>; + +export async function handler(argv: ArgumentsCamelCase) { + 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); + } +} diff --git a/src/cli/users.ts b/src/cli/users.ts new file mode 100644 index 0000000..fdc96a8 --- /dev/null +++ b/src/cli/users.ts @@ -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 '; +export const desc = 'Manage authenticated Nintendo Accounts'; + +export function builder(yargs: Argv) { + 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 ', '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 ', '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]); + }); +} diff --git a/src/cli/webservices.ts b/src/cli/webservices.ts new file mode 100644 index 0000000..d6199cd --- /dev/null +++ b/src/cli/webservices.ts @@ -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) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + 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()); +} diff --git a/src/cli/webservicetoken.ts b/src/cli/webservicetoken.ts new file mode 100644 index 0000000..fc8faf9 --- /dev/null +++ b/src/cli/webservicetoken.ts @@ -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 '; +export const desc = 'Get a token for a web service'; + +export function builder(yargs: Argv) { + 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>; + +export async function handler(argv: ArgumentsCamelCase) { + 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); +} diff --git a/src/titles.ts b/src/titles.ts index 492ec90..735d793 100644 --- a/src/titles.ts +++ b/src/titles.ts @@ -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', diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..9038059 --- /dev/null +++ b/src/util.ts @@ -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 ? R : any; +export type Argv = yargs.Argv; +export type ArgumentsCamelCase = yargstypes.ArgumentsCamelCase; + +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; +}