mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-03-21 18:04:10 -05:00
Initial commit
This commit is contained in:
parent
fc217056e1
commit
0bdd4b4614
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"files.associations": {
|
||||
"**/*/data/*": "json"
|
||||
}
|
||||
"**/data/*": "json"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
|
|
|||
14
README.md
Normal file
14
README.md
Normal 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
194
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
12
package.json
12
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",
|
||||
|
|
|
|||
535
src/api.ts
535
src/api.ts
|
|
@ -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
80
src/api/f.ts
Normal 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
138
src/api/na.ts
Normal 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
11
src/api/util.ts
Normal 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
135
src/api/znc-types.ts
Normal 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
164
src/api/znc.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
347
src/cli.ts
347
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<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
61
src/cli/announcements.ts
Normal 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
78
src/cli/friends.ts
Normal 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
9
src/cli/index.ts
Normal 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
220
src/cli/notify.ts
Normal 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
277
src/cli/presence.ts
Normal 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
64
src/cli/token.ts
Normal 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
44
src/cli/user.ts
Normal 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
95
src/cli/users.ts
Normal 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
57
src/cli/webservices.ts
Normal 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());
|
||||
}
|
||||
61
src/cli/webservicetoken.ts
Normal file
61
src/cli/webservicetoken.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
127
src/util.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user