Compare commits

...

22 Commits
main ... v1.6.1

Author SHA1 Message Date
Samuel Elliott
6da54cde09
v1.6.1 2023-03-01 11:15:26 +00:00
Samuel Elliott
d64fbe2885
Send coral platform/version to f generation API
# Conflicts:
#	src/api/coral.ts
2023-03-01 09:24:02 +00:00
Samuel Elliott
dba01ce9fb
Fix opening external links in SplatNet 3 2023-03-01 09:23:29 +00:00
Samuel Elliott
80f4d18ed7
Update Discord titles
https://github.com/samuelthomas2774/nxapi/issues/55
2023-03-01 09:23:28 +00:00
Samuel Elliott
7df6602141
Add support for Eggstra Work and Tableturf Battle for Splatoon 3 presence 2023-03-01 09:20:40 +00:00
Samuel Elliott
bc5e9a5673
Update SplatNet 3 version 2023-03-01 09:20:40 +00:00
Samuel Elliott
34364c2ff2
Skip TypeScript when bundling 2023-03-01 09:20:40 +00:00
Samuel Elliott
1fcb0e5936
Update splatnet3-types 2023-03-01 09:20:39 +00:00
Samuel Elliott
b2697b5aec
Update Discord titles 2023-03-01 09:20:39 +00:00
Samuel Elliott
f22631c8aa
Fix SplatNet 3 monitoring when no battle/coop history/album photos exist 2023-01-25 14:56:30 +00:00
Samuel Elliott
c099234586
Update splatnet3-types 2023-01-25 14:56:29 +00:00
Samuel Elliott
5d279e2efa
Update Discord titles
https://github.com/samuelthomas2774/nxapi/issues/52
2023-01-25 14:56:29 +00:00
Samuel Elliott
df69814038
Update README.md 2023-01-25 14:56:28 +00:00
Samuel Elliott
8d8672a7e1
Fix app development command from another directory 2023-01-25 14:56:28 +00:00
Samuel Elliott
0a17dd3cf0
Fix handling timeouts as a temporary error 2023-01-25 14:56:27 +00:00
Samuel Elliott
95fb4b57d8
Handle non-standard Cloudflare temporary errors 2023-01-25 14:56:27 +00:00
Samuel Elliott
31dbd810e9
Update Tricolour stage images for Splatoon 3 Discord presence 2023-01-25 14:56:26 +00:00
Samuel Elliott
59a6e3917f
Fix update interval for friend code data 2023-01-25 14:56:26 +00:00
Samuel Elliott
34d6a47322
Handle HTTP 502/503/504 errors as temporary 2023-01-25 14:56:25 +00:00
Samuel Elliott
67ab81a612
Fix handling timeouts as a temporary error 2023-01-25 14:56:25 +00:00
Samuel Elliott
5ce6f89596
Handle ECONNRESET as a temporary error 2023-01-25 14:56:24 +00:00
Samuel Elliott
273236e618
Fix presence server vote state for all users 2023-01-25 14:56:24 +00:00
26 changed files with 379 additions and 235 deletions

View File

@ -10,13 +10,14 @@ JavaScript library and command line and Electron app for accessing the Nintendo
- Command line and Electron app interfaces
- Interactive Nintendo Account login for the Nintendo Switch Online and Nintendo Switch Parental Controls apps
- Automated login to the Nintendo Switch Online app API
- This uses the imink API by default.
- This uses the [api.imink.app](https://github.com/imink-app/f-API) or
[nxapi-znca-api.fancy.org.uk](https://github.com/samuelthomas2774/nxapi-znca-api) API by default.
- Alternatively a custom server can be used.
- A custom server using a rooted Android device/emulator is included.
- Get Nintendo Switch account information, friends list and game-specific services
- Show Discord Rich Presence using your own or a friend's Nintendo Switch presence
- Show your account's friend code (or a custom friend code)
- Fetch presence from a custom URL
- Show Discord Rich Presence using your Nintendo Switch presence
- Fetch presence using a secondary account or from a custom URL.
- Show your account's friend code (or a custom friend code).
- All titles are supported using a default Nintendo Switch app. A limited number of titles have their own
Discord apps (meaning they appear under your name with the title's name instead of "Nintendo Switch")
or other custom Discord features. [See here for Discord title overrides](src/discord/titles) or
@ -32,14 +33,12 @@ JavaScript library and command line and Electron app for accessing the Nintendo
![Screenshot showing a presence notification](resources/notification.png)
- [Electron app] Open game-specific services
- Including NookLink, which doesn't work in web browsers as it requires custom JavaScript APIs.
- Nintendo Switch Online app API proxy server
- Nintendo Switch Online app API proxy server and presence server
- This allows a single account to fetch presence for multiple users.
- Data will be cached for a short time to reduce the number of requests to Nintendo's server.
- This automatically handles authentication when given a Nintendo Account session token. This makes it much
easier to access the API from a browser, in scripts or in other software.
- Download all personalised SplatNet 2 data, including battle and Salmon Run results
- This supports monitoring the authenticated user's presence and only checking for new data when playing
Splatoon 2 online.
- Download all personalised SplatNet 2 and SplatNet 3 data, including battle and Salmon Run results
- Download island newspapers from and send messages and reactions using NookLink
- Download all Nintendo Switch Parental Controls usage records
@ -49,15 +48,16 @@ The API library and types are exported for use in JavaScript/TypeScript software
nxapi includes an Electron app, which can be downloaded [here](https://github.com/samuelthomas2774/nxapi/releases). The app can be used to:
- Login to a Nintendo Account, both for the Nintendo Switch Online app and Parental Controls app.
- Login to a Nintendo Account, both for the Nintendo Switch Online app and Parental Controls app
- This will open the Nintendo Account login page in the app, just like signing into Nintendo's own apps.
- The Nintendo Account authorisation page can be opened in a browser by holding <kbd>Shift</kbd> while pressing add account.
- Accounts are shared with the nxapi command line interface.
- Share Nintendo Switch presence to Discord.
- Share Nintendo Switch presence to Discord
- Using a custom presence URL or a friend's presence is supported.
- Using the authenticated user's presence is not supported, as this is no longer available from the API ([#1](https://github.com/samuelthomas2774/nxapi/issues/1)).
- Showing notifications for friend presences.
- Showing notifications for friend presences
- Multiple users can be selected.
- Access game-specific services.
- Access game-specific services
- These will be opened in the app.
![Screenshot of the app](resources/app.png)
@ -83,7 +83,7 @@ No.
The only requirement to use this is that your Nintendo Account is linked to a Network Service Account, i.e. you've linked your Nintendo Account to a Nintendo Switch console at some point. It doesn't matter if your account is no longer linked to any console.
You will need to have had an online membership (free trial is ok) to use any game-specific services if you want to access those. SplatNet 2 can be used without an active membership, but NookLink and Smash World both require an active membership just to open them.
You will need to have an online membership (free trial is ok) to use any game-specific services if you want to access those. SplatNet 2 can be used without an active membership, but NookLink and Smash World both require an active membership just to open them.
For Parental Controls data, you don't need to have linked your account to a console. You will need to use Nintendo's app to add a console to your account though, as this isn't supported in nxapi and the Parental Controls API is a bit useless without doing this.
@ -95,11 +95,11 @@ No.
It's extremely unlikely:
- Other projects (e.g. splatnet2statink, splatoon2.ink) have used the same reverse engineered APIs for a long time (pretty much since they've existed) and no one has ever been banned for using them. splatnet2statink in monitoring mode updates every 5 minutes by default - monitoring commands (Discord presence, friend notifications and SplatNet 2 monitoring) in nxapi only update slightly more frequently (every 1 minute), so there's not much higher risk than using splatnet2statink.
- Other projects (e.g. splatnet2statink, splatoon2.ink) have used the same reverse engineered APIs for a long time (pretty much since they've existed) and no one has ever been banned for using them. splatnet2statink in monitoring mode updates every 5 minutes by default - monitoring commands (Discord presence, friend notifications and SplatNet 2/SplatNet 3 monitoring) in nxapi only update slightly more frequently (every 1 minute), so there's not much higher risk than using splatnet2statink.
- Unlike console bans, account bans would prevent you from accessing digital content or online services you've paid for. (If your console was banned you'd still be able to use it and you could just buy another one to access your account.)
- Nintendo can't stop you watching their app's network activity, which is all the reverse engineering required to develop this.
For Discord Rich Presence, you can create an additional account, add your main account as a friend and use the `--friend-nsaid` option to avoid automating your main account. Once set up, you can remove the additional account from any console. You can also set up an API proxy server (with a HTTPS reverse proxy), authenticate using a separate account, and set up API proxy authentication tokens to allow up to 300 users (max. Nintendo Switch friends) to set up Discord Rich Presence (or, get their Nintendo Switch presence in a simple HTTP request to use for anything else) without any additional requests to Nintendo.
A secondary account is required for Discord Rich Presence; you don't need to sign in to your main account.
#### Why is a token sent to one/two different non-Nintendo servers?
@ -156,9 +156,9 @@ npx rollup --config
# nxapi app or node bin/nxapi.js app to run the app
# Build Docker image
docker build . --tag gitlab.fancy.org.uk:5005/samuel/nxapi
docker build . --tag registry.fancy.org.uk/samuel/nxapi
# # Run in Docker
# docker run -it --rm -v ./data:/data gitlab.fancy.org.uk:5005/samuel/nxapi ...
# docker run -it --rm -v ./data:/data registry.fancy.org.uk/samuel/nxapi ...
```
### Usage
@ -273,7 +273,7 @@ See [docs/lib](docs/lib/index.md) and [src/exports](src/exports).
### Coral client authentication
The [imink API](https://github.com/JoneWang/imink/wiki/imink-API-Documentation) is used by default to automate authenticating to the Nintendo Switch Online app's API and authenticating to web services. An access token (`id_token`) created by Nintendo must be sent to this API to generate some data that is required to authenticate the app. This API runs the Nintendo Switch Online app on an Android device to generate this data. The access token sent includes some information about the authenticated Nintendo Account and can be used to authenticate to the Nintendo Switch Online app and web services.
[api.imink.app](https://github.com/JoneWang/imink/wiki/imink-API-Documentation) or [nxapi-znca-api.fancy.org.uk](https://github.com/samuelthomas2774/nxapi-znca-api) is used by default to automate authenticating to the Nintendo Switch Online app's API and authenticating to web services. An access token (`id_token`) created by Nintendo must be sent to this API to generate some data that is required to authenticate the app. This API runs the Nintendo Switch Online app on an Android device to generate this data. The access token sent includes some information about the authenticated Nintendo Account and can be used to authenticate to the Nintendo Switch Online app and web services.
Specifically, the tokens sent are JSON Web Tokens. The token sent to login to the app includes [this information and is valid for 15 minutes](https://gitlab.fancy.org.uk/samuel/nxapi/-/wikis/Nintendo-tokens#nintendo-account-id_token), and the token sent to login to web services includes [this information and is valid for two hours](https://gitlab.fancy.org.uk/samuel/nxapi/-/wikis/Nintendo-tokens#nintendo-switch-online-app-token).
@ -313,10 +313,11 @@ The reason Nintendo added this is probably to try and stop people automating acc
- https://github.com/Quark064/NSO-Discord-Integration
- https://github.com/AAGaming00/acnhrp - doesn't use Coral, instead attempts to send a message in Animal Crossing: New Horizons every 10 seconds to check if the user is playing that game online
- Other projects using Coral/web services
- (Due to frequent breaking changes to Nintendo's API many of these do not work. In particular anything not updated since [23/08/2022](https://github.com/samuelthomas2774/nxapi/discussions/10#discussioncomment-3464443) will not be able to authenticate to Nintendo's API.)
- https://github.com/frozenpandaman/splatnet2statink
- https://github.com/subnode/LoungeDesktop
- https://github.com/dqn/gonso
- https://github.com/clovervidia/splatnet-datagrabber
- https://github.com/mizuyoukanao/ACNH_Chat_Client
- https://github.com/dqn/acnh
- ... plus many more - [search GitHub for https://elifessler.com/s2s/api/gen2](https://github.com/search?q=https%3A%2F%2Felifessler.com%2Fs2s%2Fapi%2Fgen2&type=code)
- ... plus many more - [search GitHub for znc.srv.nintendo.net](https://github.com/search?q=znc.srv.nintendo.net&type=code)

52
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"body-parser": "^1.20.1",
@ -23,7 +23,7 @@
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"splatnet3-types": "^0.2.20221212221842",
"splatnet3-types": "^0.2.20230227204004",
"supports-color": "^8.1.1",
"tslib": "^2.4.1",
"uuid": "^8.3.2",
@ -39,7 +39,6 @@
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^14.1.0",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.5.0",
"@types/body-parser": "^1.19.2",
"@types/cli-table": "^0.3.1",
"@types/debug": "^4.1.7",
@ -348,29 +347,6 @@
"rollup": "^1.20.0 || ^2.0.0"
}
},
"node_modules/@rollup/plugin-typescript": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-8.5.0.tgz",
"integrity": "sha512-wMv1/scv0m/rXx21wD2IsBbJFba8wGF3ErJIr6IKRfRj49S85Lszbxb4DCo8iILpluTjk2GAAu9CoZt4G3ppgQ==",
"dev": true,
"dependencies": {
"@rollup/pluginutils": "^3.1.0",
"resolve": "^1.17.0"
},
"engines": {
"node": ">=8.0.0"
},
"peerDependencies": {
"rollup": "^2.14.0",
"tslib": "*",
"typescript": ">=3.7.0"
},
"peerDependenciesMeta": {
"tslib": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
@ -4065,9 +4041,9 @@
"dev": true
},
"node_modules/splatnet3-types": {
"version": "0.2.20221212221842",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.2.20221212221842.tgz",
"integrity": "sha512-A/fs/0mUBpdH2q2ye7z5fbUFOFJdmD9t0j36RZ0fpTm8hiA0orjZ15l8FJ1gZTo8xVtzYbA9cQWjq/dth0nPmw=="
"version": "0.2.20230227204004",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.2.20230227204004.tgz",
"integrity": "sha512-FAY6pbUcrp5O8c49BNXSKxoyM3UlCrRx2AtA9Y3qlvqOLdHqwxtzcdzbk1b1hRam8ZcrxRzE/ii6ESRiPIAnZw=="
},
"node_modules/sprintf-js": {
"version": "1.1.2",
@ -4850,16 +4826,6 @@
"magic-string": "^0.25.7"
}
},
"@rollup/plugin-typescript": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-8.5.0.tgz",
"integrity": "sha512-wMv1/scv0m/rXx21wD2IsBbJFba8wGF3ErJIr6IKRfRj49S85Lszbxb4DCo8iILpluTjk2GAAu9CoZt4G3ppgQ==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.1.0",
"resolve": "^1.17.0"
}
},
"@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
@ -7769,9 +7735,9 @@
"dev": true
},
"splatnet3-types": {
"version": "0.2.20221212221842",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.2.20221212221842.tgz",
"integrity": "sha512-A/fs/0mUBpdH2q2ye7z5fbUFOFJdmD9t0j36RZ0fpTm8hiA0orjZ15l8FJ1gZTo8xVtzYbA9cQWjq/dth0nPmw=="
"version": "0.2.20230227204004",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.2.20230227204004.tgz",
"integrity": "sha512-FAY6pbUcrp5O8c49BNXSKxoyM3UlCrRx2AtA9Y3qlvqOLdHqwxtzcdzbk1b1hRam8ZcrxRzE/ii6ESRiPIAnZw=="
},
"sprintf-js": {
"version": "1.1.2",

View File

@ -1,6 +1,6 @@
{
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"description": "Nintendo Switch app APIs",
"license": "AGPL-3.0-or-later",
"author": "Samuel Elliott <samuel+nxapi@fancy.org.uk>",
@ -49,7 +49,7 @@
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"splatnet3-types": "^0.2.20221212221842",
"splatnet3-types": "^0.2.20230227204004",
"supports-color": "^8.1.1",
"tslib": "^2.4.1",
"uuid": "^8.3.2",
@ -62,7 +62,6 @@
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^14.1.0",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.5.0",
"@types/body-parser": "^1.19.2",
"@types/cli-table": "^0.3.1",
"@types/debug": "^4.1.7",

View File

@ -17,8 +17,8 @@
"blanco_version": "2.1.1"
},
"coral_gws_splatnet3": {
"app_ver": "2.0.0-bd36a652",
"version": "2.0.0",
"revision": "bd36a652913aa26b132b84df921e07b20f4a414d"
"app_ver": "3.0.0-2857bc50",
"version": "3.0.0",
"revision": "2857bc50653d316cb69f017b2eef24d2ae56a1b7"
}
}

View File

@ -2,8 +2,8 @@ import * as path from 'path';
import { fileURLToPath } from 'url';
import * as fs from 'fs';
import * as child_process from 'child_process';
import { Module } from 'module';
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import alias from '@rollup/plugin-alias';
import replace from '@rollup/plugin-replace';
@ -43,7 +43,7 @@ const release = process.env.NODE_ENV === 'production' ? process.env.CI_COMMIT_TA
* @type {import('@rollup/plugin-replace').RollupReplaceOptions}
*/
const replace_options = {
include: ['src/util/product.ts'],
include: ['dist/util/product.js'],
values: {
'globalThis.__NXAPI_BUNDLE_PKG__': JSON.stringify(pkg),
'globalThis.__NXAPI_BUNDLE_GIT__': JSON.stringify(git),
@ -58,14 +58,14 @@ const replace_options = {
* @type {import('rollup').RollupOptions['watch']}
*/
const watch = {
include: 'src/**',
include: 'dist/**',
};
/**
* @type {import('rollup').RollupOptions}
*/
const main = {
input: ['src/cli-entry.ts', 'src/app/main/index.ts'],
input: ['dist/cli-entry.js', 'dist/app/main/index.js'],
output: {
dir: 'dist/bundle',
format: 'es',
@ -79,19 +79,17 @@ const main = {
},
plugins: [
replace(replace_options),
typescript({
outDir: 'dist/bundle/ts',
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
// events and stream modify module.exports
requireReturnsDefault: 'preferred',
}),
json(),
alias({
entries: [
...Module.builtinModules.map(m => ({find: m, replacement: 'node:' + m})),
],
}),
nodeResolve({
exportConditions: ['node'],
browser: false,
@ -110,29 +108,26 @@ const main = {
* @type {import('rollup').RollupOptions}
*/
const app_entry = {
input: 'src/app/app-entry.cts',
input: 'dist/app/app-entry.cjs',
output: {
file: 'dist/bundle/app-entry.cjs',
format: 'iife',
inlineDynamicImports: true,
sourcemap: true,
globals: {
'electron': 'require("electron")',
},
},
plugins: [
replace(replace_options),
replace({
include: ['src/app/app-entry.cts'],
include: ['dist/app/app-entry.cjs'],
values: {
'__NXAPI_BUNDLE_APP_MAIN__': JSON.stringify('./app-main-bundle.js'),
},
preventAssignment: true,
}),
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
// events and stream modify module.exports
requireReturnsDefault: 'preferred',
@ -146,7 +141,8 @@ const app_entry = {
],
external: [
'electron',
path.resolve(__dirname, 'src/app/app-main-bundle.js'),
path.resolve(__dirname, 'dist/app/app-main-bundle.js'),
path.resolve(__dirname, 'dist/app/main/index.js'),
],
watch,
};
@ -155,7 +151,7 @@ const app_entry = {
* @type {import('rollup').RollupOptions}
*/
const app_preload = {
input: 'src/app/preload/index.ts',
input: 'dist/app/preload/index.js',
output: {
file: 'dist/app/bundle/preload.cjs',
format: 'cjs',
@ -163,13 +159,7 @@ const app_preload = {
},
plugins: [
replace(replace_options),
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
nodeResolve({
@ -187,20 +177,14 @@ const app_preload = {
* @type {import('rollup').RollupOptions}
*/
const app_preload_webservice = {
input: 'src/app/preload-webservice/index.ts',
input: 'dist/app/preload-webservice/index.js',
output: {
file: 'dist/app/bundle/preload-webservice.cjs',
format: 'cjs',
},
plugins: [
replace(replace_options),
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
nodeResolve({
@ -218,7 +202,7 @@ const app_preload_webservice = {
* @type {import('rollup').RollupOptions}
*/
const app_browser = {
input: 'src/app/browser/index.ts',
input: 'dist/app/browser/index.js',
output: {
dir: 'dist/app/bundle',
format: 'es',
@ -238,14 +222,7 @@ const app_browser = {
title: 'nxapi',
}),
replace(replace_options),
typescript({
outDir: 'dist/app/bundle/ts',
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
nodePolyfill(),
@ -255,6 +232,7 @@ const app_browser = {
// used when resolving react-native-web. For some reason this causes both versions
// to be included in the bundle, so here we explicitly use the CommonJS build.
{find: 'react-native', replacement: path.resolve(__dirname, 'node_modules', 'react-native-web', 'dist', 'cjs', 'index.js')},
{find: 'react-native-web', replacement: path.resolve(__dirname, 'node_modules', 'react-native-web', 'dist', 'cjs', 'index.js')},
// rollup-plugin-polyfill-node doesn't support node: module identifiers
{find: /^node:(.+)/, replacement: '$1'},
@ -268,10 +246,12 @@ const app_browser = {
watch,
};
const skip = process.env.BUNDLE_SKIP?.split(',') ?? [];
export default [
main,
app_entry,
app_preload,
app_preload_webservice,
app_browser,
];
!skip?.includes('main') && main,
!skip?.includes('app-entry') && app_entry,
!skip?.includes('app-preload') && app_preload,
!skip?.includes('app-preload-webservice') && app_preload_webservice,
!skip?.includes('app-browser') && app_browser,
].filter(c => c);

View File

@ -210,7 +210,11 @@ export default class CoralApi {
async getWebServiceToken(id: number, /** @internal */ _attempt = 0): Promise<Result<WebServiceToken>> {
await this._renewToken;
const data = await f(this.token, HashMethod.WEB_SERVICE, this.useragent ?? getAdditionalUserAgents());
const data = await f(this.token, HashMethod.WEB_SERVICE, {
platform: ZNCA_PLATFORM,
version: this.znca_version,
useragent: this.useragent ?? getAdditionalUserAgents(),
});
const req: WebServiceTokenParameter = {
id,
@ -242,8 +246,11 @@ export default class CoralApi {
// Nintendo Account token
const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID);
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL,
this.useragent ?? getAdditionalUserAgents());
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, {
platform: ZNCA_PLATFORM,
version: this.znca_version,
useragent: this.useragent ?? getAdditionalUserAgents(),
});
const req: AccountTokenParameter = {
naBirthday: user.birthday,
@ -301,7 +308,11 @@ export default class CoralApi {
// Nintendo Account user data
const user = await getNintendoAccountUser(nintendoAccountToken);
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, useragent);
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, {
platform: ZNCA_PLATFORM,
version: config.znca_version,
useragent,
});
debug('Getting Nintendo Switch Online app token');

View File

@ -1,5 +1,5 @@
import process from 'node:process';
import fetch from 'node-fetch';
import fetch, { Headers } from 'node-fetch';
import createDebug from 'debug';
import { v4 as uuidgen } from 'uuid';
import { defineResponse, ErrorResponse } from './util.js';
@ -239,10 +239,11 @@ export class ZncaApiImink extends ZncaApi {
export async function genf(
url: string, hash_method: HashMethod,
token: string, timestamp?: number, request_id?: string,
useragent?: string
app?: {platform?: string; version?: string;}, useragent?: string
) {
debugZncaApi('Getting f parameter', {
url, hash_method, token, timestamp, request_id,
znca_platform: app?.platform, znca_version: app?.version,
});
const req: AndroidZncaFRequest = {
@ -252,13 +253,17 @@ export async function genf(
request_id,
};
const headers = new Headers({
'Content-Type': 'application/json',
'User-Agent': getUserAgent(useragent),
});
if (app?.platform) headers.append('X-znca-Platform', app.platform);
if (app?.version) headers.append('X-znca-Version', app.version);
const [signal, cancel] = timeoutSignal();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': getUserAgent(useragent),
},
headers,
body: JSON.stringify(req),
signal,
}).finally(cancel);
@ -296,14 +301,14 @@ export interface AndroidZncaFError {
}
export class ZncaApiNxapi extends ZncaApi {
constructor(readonly url: string, useragent?: string) {
constructor(readonly url: string, readonly app?: {platform?: string; version?: string;}, useragent?: string) {
super(useragent);
}
async genf(token: string, hash_method: HashMethod) {
const request_id = uuidgen();
const result = await genf(this.url + '/f', hash_method, token, undefined, request_id, this.useragent);
const result = await genf(this.url + '/f', hash_method, token, undefined, request_id, this.app, this.useragent);
return {
provider: 'nxapi' as const,
@ -316,10 +321,13 @@ export class ZncaApiNxapi extends ZncaApi {
}
}
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, useragent?: string): Promise<FResult> {
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, options?: ZncaApiOptions): Promise<FResult>;
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, useragent?: string): Promise<FResult>;
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, options?: ZncaApiOptions | string): Promise<FResult> {
if (typeof options === 'string') options = {useragent: options};
if (typeof hash_method === 'string') hash_method = parseInt(hash_method);
const provider = getPreferredZncaApiFromEnvironment(useragent) ?? await getDefaultZncaApi(useragent);
const provider = getPreferredZncaApiFromEnvironment(options) ?? await getDefaultZncaApi(options);
return provider.genf(token, hash_method);
}
@ -344,37 +352,51 @@ export type FResult = {
result: AndroidZncaFResponse;
});
export function getPreferredZncaApiFromEnvironment(useragent?: string): ZncaApi | null {
interface ZncaApiOptions {
useragent?: string;
platform?: string;
version?: string;
}
export function getPreferredZncaApiFromEnvironment(options?: ZncaApiOptions): ZncaApi | null;
export function getPreferredZncaApiFromEnvironment(useragent?: string): ZncaApi | null;
export function getPreferredZncaApiFromEnvironment(options?: ZncaApiOptions | string): ZncaApi | null {
if (typeof options === 'string') options = {useragent: options};
if (process.env.NXAPI_ZNCA_API) {
if (process.env.NXAPI_ZNCA_API === 'flapg') {
return new ZncaApiFlapg(useragent);
return new ZncaApiFlapg(options?.useragent);
}
if (process.env.NXAPI_ZNCA_API === 'imink') {
return new ZncaApiImink(useragent);
return new ZncaApiImink(options?.useragent);
}
throw new Error('Unknown znca API provider');
}
if (process.env.ZNCA_API_URL) {
return new ZncaApiNxapi(process.env.ZNCA_API_URL, useragent);
return new ZncaApiNxapi(process.env.ZNCA_API_URL, options, options?.useragent);
}
return null;
}
export async function getDefaultZncaApi(useragent?: string) {
export async function getDefaultZncaApi(options?: ZncaApiOptions): Promise<ZncaApi>;
export async function getDefaultZncaApi(useragent?: string): Promise<ZncaApi>;
export async function getDefaultZncaApi(options?: ZncaApiOptions | string) {
if (typeof options === 'string') options = {useragent: options};
const { default: { coral_auth: { default: provider } } } = await import('../common/remote-config.js');
if (provider === 'flapg') {
return new ZncaApiFlapg(useragent);
return new ZncaApiFlapg(options?.useragent);
}
if (provider === 'imink') {
return new ZncaApiImink(useragent);
return new ZncaApiImink(options?.useragent);
}
if (provider[0] === 'nxapi') {
return new ZncaApiNxapi(provider[1], useragent);
return new ZncaApiNxapi(provider[1], options, options?.useragent);
}
throw new Error('Invalid znca API provider');

View File

@ -51,6 +51,16 @@ export interface PersistedQueryResultData {
[VariablesSymbol]: {};
}
export type NotNullPersistedQueryResult<
T extends PersistedQueryResult<unknown> | unknown,
K extends T extends PersistedQueryResult<infer Result> ? keyof Result : keyof T,
> =
T extends PersistedQueryResult<infer Result> ? PersistedQueryResult<{
[FieldName in keyof Result]: FieldName extends K ? Exclude<Result[FieldName], null> : Result[FieldName];
}> : PersistedQueryResult<{
[FieldName in keyof T]: FieldName extends K ? Exclude<T[FieldName], null> : T[FieldName];
}>;
enum MapQueriesMode {
/** NXAPI_SPLATNET3_UPGRADE_QUERIES=0 - never upgrade persisted query IDs (not recommended) */
NEVER,
@ -75,6 +85,7 @@ export default class SplatNet3Api {
public version: string,
public map_queries: Partial<Record<string, [/** new query ID */ string, /** unsafe */ boolean] | null>>,
readonly map_queries_mode: MapQueriesMode,
readonly na_country: string,
public language: string,
public useragent: string,
) {}
@ -234,12 +245,16 @@ export default class SplatNet3Api {
/** / */
async getHome() {
return this.persistedQuery(RequestId.HomeQuery, {});
return this.persistedQuery(RequestId.HomeQuery, {
naCountry: this.na_country,
});
}
/** / -> /setting */
async getSettings() {
return this.persistedQuery(RequestId.SettingQuery, {});
return this.persistedQuery(RequestId.SettingQuery, {
naCountry: this.na_country,
});
}
/** / -> /photo_album */
@ -353,7 +368,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'journey'>;
}
/** / -> /challenge -> /challenge/{id} -> pull-to-refresh */
@ -366,7 +381,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'journey'>;
}
/** / -> /challenge -> /challenge/{id} -> /challenge/{id}/*s */
@ -379,7 +394,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'journey'>;
}
/** / -> /challenge -> /challenge/{id} -> /challenge/{id}/* -> pull-to-refresh */
@ -392,7 +407,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'journey'>;
}
/** / -> /challenge -> /challenge/{id} -> /challenge/{id}/* -> support */
@ -426,7 +441,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
}
/** / -> /fest_record -> /fest_record/{id} -> pull-to-refresh */
@ -439,7 +454,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
}
/** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} */
@ -452,7 +467,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
}
/** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} -> pull-to-refresh */
@ -465,7 +480,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
}
/** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} - not voted in game */
@ -485,7 +500,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
}
/**
@ -504,7 +519,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] FestTeam not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'node'>;
}
//
@ -609,7 +624,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Sale gear not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'saleGear'>;
}
/** / -> /gesotown -> /gesotown/{id} -> order */
@ -646,7 +661,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] My outfit not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'myOutfit'>;
}
/** / -> /my_outfits -> /my_outfits/{id / create} */
@ -683,6 +698,14 @@ export default class SplatNet3Api {
});
}
/** / -> /my_outfits [-> /my_outfits/{id}] -> share */
async shareOutfit(index: number, timezone_offset_minutes = 0) {
return this.persistedQuery(RequestId.ShareMyOutfitQuery, {
myOutfitIndex: index,
timezoneOffset: timezone_offset_minutes, // (new Date()).getTimezoneOffset()
});
}
//
// Replays
//
@ -709,7 +732,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Replay not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'replay'>;
}
/** / -> /replay -> enter code -> download */
@ -810,7 +833,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Battle history not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'vsHistoryDetail'>;
}
/** / -> /history -> /history/detail/{id} -> pull-to-refresh */
@ -823,7 +846,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Battle history not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'vsHistoryDetail'>;
}
/** / -> /history -> /history/detail/* -> latest */
@ -869,7 +892,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Co-op history not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'coopHistoryDetail'>;
}
/** / -> /coop -> /coop/{id} -> pull-to-refresh */
@ -882,7 +905,7 @@ export default class SplatNet3Api {
throw new ErrorResponse('[splatnet3] Co-op history not found', result[ResponseSymbol], result);
}
return result;
return result as NotNullPersistedQueryResult<typeof result, 'node'>;
}
/** / -> /coop -> /coop/* -> latest */
@ -923,6 +946,7 @@ export default class SplatNet3Api {
data.version,
data.queries ?? {},
getMapPersistedQueriesModeFromEnvironment(),
data.country,
data.bullet_token.lang,
data.useragent,
);
@ -934,6 +958,7 @@ export default class SplatNet3Api {
data.version,
data.queries ?? {},
getMapPersistedQueriesModeFromEnvironment(),
data.country ?? 'GB',
data.language,
SPLATNET3_WEBSERVICE_USERAGENT,
);
@ -1071,6 +1096,7 @@ export interface SplatNet3CliTokenData {
bullet_token: string;
expires_at: number;
language: string;
country: string;
version: string;
queries?: Partial<Record<string, [/** new query ID */ string, /** unsafe */ boolean] | null>>;
}

View File

@ -68,6 +68,8 @@ export default async function openWebService(
if (!isWebServiceUrlAllowed(webservice, url)) {
debug('Web service attempted to navigate to a URL not allowed by it\'s `whiteList`', webservice, url);
debug('open', url);
shell.openExternal(url);
event.preventDefault();
}
});

View File

@ -1,9 +1,11 @@
import process from 'node:process';
import { createRequire } from 'node:module';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
import { dir } from '../util/product.js';
const debug = createDebug('cli:app');
@ -25,7 +27,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
execFileSync(electron, [
'dist/app/app-entry.cjs',
path.resolve(dir, 'dist', 'app', 'app-entry.cjs'),
], {
stdio: 'inherit',
env: {

View File

@ -693,7 +693,7 @@ class Server extends HttpServer {
throw err;
}
}, promises, this.cached_friendcode_data);
}, promises, this.cached_friendcode_data, this.friendcode_update_interval);
} finally {
if (!promises.size) this.friendcode_data_promise.delete(id);
}

View File

@ -18,6 +18,7 @@ import SplatNet3Api from '../api/splatnet3.js';
import { ErrorResponse } from '../api/util.js';
import { EventStreamResponse, HttpServer, ResponseError } from './util/http-server.js';
import { getTitleIdFromEcUrl } from '../util/misc.js';
import StageScheduleQuery_730cd98 from 'splatnet3-types/graphql/730cd98e84f1030d3e9ac86b6f1aae13';
const debug = createDebug('cli:presence-server');
const debugSplatnet3Proxy = createDebug('cli:presence-server:splatnet3-proxy');
@ -25,7 +26,7 @@ const debugSplatnet3Proxy = createDebug('cli:presence-server:splatnet3-proxy');
interface AllUsersResult extends Friend {
title: TitleResult | null;
splatoon3?: Friend_friendList | null;
splatoon3_fest_team?: FestTeam_votingStatus | null;
splatoon3_fest_team?: (FestTeam_schedule & FestTeam_votingStatus) | null;
}
interface PresenceResponse {
friend: Friend;
@ -440,7 +441,10 @@ class Server extends HttpServer {
for (const player of team.votes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
match.splatoon3_fest_team = createFestVoteTeam(team, FestVoteState.VOTED);
match.splatoon3_fest_team = {
...createFestVoteTeam(team, FestVoteState.VOTED),
myVoteState: FestVoteState.VOTED,
};
break;
}
@ -449,7 +453,10 @@ class Server extends HttpServer {
for (const player of team.preVotes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
match.splatoon3_fest_team = createFestVoteTeam(team, FestVoteState.PRE_VOTED);
match.splatoon3_fest_team = {
...createFestVoteTeam(team, FestVoteState.PRE_VOTED),
myVoteState: FestVoteState.PRE_VOTED,
};
break;
}
@ -653,8 +660,8 @@ class Server extends HttpServer {
if (vs_mode.mode === 'FEST') {
return getSchedule(schedules.festSchedules)?.festMatchSetting;
}
if (vs_mode.mode === 'LEAGUE') {
return getSchedule(schedules.leagueSchedules)?.leagueMatchSetting;
if (vs_mode.mode === 'LEAGUE' && 'leagueSchedules' in schedules) {
return getSchedule((schedules as StageScheduleQuery_730cd98).leagueSchedules)?.leagueMatchSetting;
}
if (vs_mode.mode === 'X_MATCH') {
return getSchedule(schedules.xSchedules)?.xMatchSetting;

View File

@ -59,7 +59,7 @@ export async function dumpAlbumPhotos(
await splatnet.getPhotoAlbum();
if (typeof refresh !== 'object' ||
results.data.photoAlbum.items.nodes[0].id !== refresh.photoAlbum.items.nodes[0].id
results.data.photoAlbum.items.nodes[0]?.id !== refresh.photoAlbum.items.nodes[0]?.id
) {
const filename = 'splatnet3-photoalbum-' + Date.now() + '.json';
const file = path.join(directory, filename);

View File

@ -2,12 +2,12 @@ import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import { BankaraBattleHistoriesRefetchResult, CoopHistoryResult, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesResult, PrivateBattleHistoriesRefetchResult, RegularBattleHistoriesRefetchResult, RequestId, XBattleHistoriesRefetchResult } from 'splatnet3-types/splatnet3';
import { BankaraBattleHistoriesRefetchResult, CoopHistoryResult, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesResult, PrivateBattleHistoriesRefetchResult, RefetchableCoopHistory_CoopResultResult, RegularBattleHistoriesRefetchResult, RequestId, XBattleHistoriesRefetchResult } from 'splatnet3-types/splatnet3';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';
import SplatNet3Api, { RequestIdSymbol } from '../../api/splatnet3.js';
import SplatNet3Api, { PersistedQueryResult, RequestIdSymbol } from '../../api/splatnet3.js';
import { ResponseSymbol } from '../../api/util.js';
import { dumpCatalogRecords, dumpHistoryRecords } from './dump-records.js';
@ -81,14 +81,15 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
export async function dumpResults(
splatnet: SplatNet3Api, directory: string,
refresh: LatestBattleHistoriesResult | boolean = false
refresh: LatestBattleHistoriesResult | boolean = false,
latest_refetch?: PersistedQueryResult<LatestBattleHistoriesRefetchResult<true>>,
) {
debug('Fetching battle results');
console.warn('Fetching battle results');
const [player, battles, battles_regular, battles_anarchy, battles_xmatch, battles_private] = await Promise.all([
refresh ? null : splatnet.getBattleHistoryCurrentPlayer(),
refresh ? splatnet.getLatestBattleHistoriesRefetch() : splatnet.getLatestBattleHistories(),
refresh ? latest_refetch ?? splatnet.getLatestBattleHistoriesRefetch() : splatnet.getLatestBattleHistories(),
refresh ? splatnet.getRegularBattleHistoriesRefetch() : splatnet.getRegularBattleHistories(),
refresh ? splatnet.getBankaraBattleHistoriesRefetch() : splatnet.getBankaraBattleHistories(),
refresh ? splatnet.getXBattleHistoriesRefetch() : splatnet.getXBattleHistories(),
@ -239,13 +240,14 @@ export async function dumpResults(
export async function dumpCoopResults(
splatnet: SplatNet3Api, directory: string,
refresh: CoopHistoryResult | boolean = false
refresh: CoopHistoryResult | boolean = false,
refetch?: PersistedQueryResult<RefetchableCoopHistory_CoopResultResult>,
) {
debug('Fetching coop results');
console.warn('Fetching coop results');
const results = refresh ?
await splatnet.getCoopHistoryRefetch() :
refetch ?? await splatnet.getCoopHistoryRefetch() :
await splatnet.getCoopHistory();
const filename = 'splatnet3-coop-summary-' + Date.now() + '.json';

View File

@ -53,7 +53,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
throw new Error('Invalid Splatfest ID');
}
const fest = (await splatnet.getFestDetail(fest_record.id)).data.fest!;
const fest = (await splatnet.getFestDetail(fest_record.id)).data.fest;
const fest_votes = fest.state !== FestState.CLOSED ?
(await splatnet.getFestVotingStatus(fest_record.id)).data.fest : null;

View File

@ -109,10 +109,10 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.warn('Monitoring for new data');
if (vs) {
const latest_id = vs.battles.data.latestBattleHistories.historyGroups.nodes[0].historyDetails.nodes[0].id;
const latest_id = vs.battles.data.latestBattleHistories.historyGroups.nodes[0]?.historyDetails.nodes[0]?.id;
// If we already had the latest battle result, fetch it again now to match the behavour of Nintendo's app
if (!vs.downloaded.includes(latest_id)) {
if (latest_id && !vs.downloaded.includes(latest_id)) {
const id_str = Buffer.from(latest_id, 'base64').toString() || latest_id;
const match = id_str.match(/^VsHistoryDetail-(u-[0-9a-z]{20}):([A-Z]+):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/);
const id = match ? match[1] + '-' + match[3] : id_str;
@ -124,10 +124,10 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
if (coop) {
const latest_id = coop.results.data.coopResult.historyGroups.nodes[0].historyDetails.nodes[0].id;
const latest_id = coop.results.data.coopResult.historyGroups.nodes[0]?.historyDetails.nodes[0]?.id;
// If we already had the latest coop result, fetch it again now to match the behavour of Nintendo's app
if (!coop.downloaded.includes(latest_id)) {
if (latest_id && !coop.downloaded.includes(latest_id)) {
const id_str = Buffer.from(latest_id, 'base64').toString() || latest_id;
const match = id_str.match(/^CoopHistoryDetail-(u-[0-9a-z]{20}):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/);
const id = match ? match[1] + '-' + match[2] : id_str;
@ -187,28 +187,51 @@ async function update(
let updated_coop = false;
if (vs) {
const latest_id = vs.battles.data.latestBattleHistories.historyGroups.nodes[0].historyDetails.nodes[0].id;
const latest_id = vs.battles.data.latestBattleHistories.historyGroups.nodes[0]?.historyDetails.nodes[0]?.id;
const pager = await splatnet.getBattleHistoryDetailPagerRefetch(latest_id);
if (latest_id) {
const pager = await splatnet.getBattleHistoryDetailPagerRefetch(latest_id);
if (pager.data.vsHistoryDetail?.nextHistoryDetail) {
// New battle results available
debug('New battle result', pager.data.vsHistoryDetail.nextHistoryDetail);
vs = await dumpResults(splatnet, directory, vs.battles.data);
updated_vs = true;
if (pager.data.vsHistoryDetail.nextHistoryDetail) {
// New battle results available
debug('New battle result', pager.data.vsHistoryDetail.nextHistoryDetail);
vs = await dumpResults(splatnet, directory, vs.battles.data);
updated_vs = true;
}
} else {
const latest_refetch = await splatnet.getLatestBattleHistoriesRefetch();
const latest_id = latest_refetch.data
.latestBattleHistories.historyGroups.nodes[0]?.historyDetails.nodes[0]?.id;
if (latest_id) {
debug('New battle result');
vs = await dumpResults(splatnet, directory, vs.battles.data, latest_refetch);
updated_vs = true;
}
}
}
if (coop) {
const latest_id = coop.results.data.coopResult.historyGroups.nodes[0].historyDetails.nodes[0].id;
const latest_id = coop.results.data.coopResult.historyGroups.nodes[0]?.historyDetails.nodes[0]?.id;
const pager = await splatnet.getCoopHistoryDetailRefetch(latest_id);
if (latest_id) {
const pager = await splatnet.getCoopHistoryDetailRefetch(latest_id);
if (pager.data.node?.nextHistoryDetail) {
// New coop results available
debug('New coop result', pager.data.node.nextHistoryDetail);
coop = await dumpCoopResults(splatnet, directory, coop.results.data);
updated_coop = true;
if (pager.data.node.nextHistoryDetail) {
// New coop results available
debug('New coop result', pager.data.node.nextHistoryDetail);
coop = await dumpCoopResults(splatnet, directory, coop.results.data);
updated_coop = true;
}
} else {
const refetch = await splatnet.getCoopHistoryRefetch();
const latest_id = refetch.data.coopResult.historyGroups.nodes[0]?.historyDetails.nodes[0]?.id;
if (latest_id) {
debug('New coop result');
coop = await dumpCoopResults(splatnet, directory, coop.results.data, refetch);
updated_coop = true;
}
}
}

View File

@ -40,6 +40,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
bullet_token: data.bullet_token.bulletToken,
expires_at: data.expires_at,
language: data.bullet_token.lang,
country: data.country,
version: data.version,
queries: data.queries,
};

View File

@ -1,10 +1,13 @@
import createDebug from 'debug';
import { NextFunction, Request, RequestHandler, Response } from 'express';
import { ErrorResponse } from '../../api/util.js';
import { temporary_http_errors, temporary_system_errors } from '../../util/errors.js';
const debug = createDebug('cli:util:http-server');
export class HttpServer {
retry_after = 60;
protected createApiRequestHandler(callback: (req: Request, res: Response) => Promise<{} | void>, auth = false) {
return async (req: Request, res: Response) => {
try {
@ -42,13 +45,17 @@ export class HttpServer {
return JSON.stringify(data, null, space);
}
protected handleRequestError(req: Request, res: Response, err: unknown) {
protected handleRequestError(req: Request, res: Response, err: unknown, retry_after = this.retry_after) {
debug('Error in request %s %s', req.method, req.url, err);
if (err instanceof ErrorResponse) {
const retry_after = err.response.headers.get('Retry-After');
const response_retry_after = err.response.headers.get('Retry-After');
if (retry_after && /^\d+$/.test(retry_after)) {
if (response_retry_after && /^\d+$/.test(response_retry_after)) {
res.setHeader('Retry-After', response_retry_after);
}
if (temporary_http_errors.includes(err.response.status) && !response_retry_after) {
res.setHeader('Retry-After', retry_after);
}
}
@ -56,8 +63,8 @@ export class HttpServer {
if (err && typeof err === 'object' && 'type' in err && 'code' in err && (err as any).type === 'system') {
const code: string = (err as any).code;
if (code === 'ETIMEDOUT' || code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
res.setHeader('Retry-After', '60');
if (code in temporary_system_errors) {
res.setHeader('Retry-After', retry_after);
}
}

View File

@ -7,8 +7,9 @@ import { ErrorResponse } from '../api/util.js';
import { SavedToken } from './auth/coral.js';
import { SplatNet2RecordsMonitor } from './splatnet2/monitor.js';
import Loop, { LoopResult } from '../util/loop.js';
import { getTitleIdFromEcUrl, hrduration, TemporaryErrorSymbol } from '../util/misc.js';
import { getTitleIdFromEcUrl, hrduration } from '../util/misc.js';
import { CoralUser } from './users.js';
import { handleError } from '../util/errors.js';
const debug = createDebug('nxapi:nso:notify');
const debugFriends = createDebug('nxapi:nso:notify:friends');
@ -183,31 +184,6 @@ export class ZncNotifications extends Loop {
}
}
export async function handleError(
err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException,
loop: Loop,
): Promise<LoopResult> {
if (TemporaryErrorSymbol in err && err[TemporaryErrorSymbol]) {
debug('Temporary error, waiting %ds before retrying', loop.update_interval, err);
return LoopResult.OK;
} else if ('code' in err && (err as any).type === 'system' && err.code === 'ETIMEDOUT') {
debug('Request timed out, waiting %ds before retrying', loop.update_interval, err);
return LoopResult.OK;
} else if ('code' in err && (err as any).type === 'system' && err.code === 'ENOTFOUND') {
debug('Request error, waiting %ds before retrying', loop.update_interval, err);
return LoopResult.OK;
} else if ('code' in err && (err as any).type === 'system' && err.code === 'EAI_AGAIN') {
debug('Request error - name resolution failed, waiting %ds before retrying', loop.update_interval, err);
return LoopResult.OK;
} else {
throw err;
}
}
export class NotificationManager {
onPresenceUpdated?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, type?: PresenceEvent, naid?: string, ir?: boolean): void;

View File

@ -3,7 +3,7 @@ import EventSource from 'eventsource';
import { DiscordRpcClient, findDiscordRpcClient } from '../discord/rpc.js';
import { getDiscordPresence, getInactiveDiscordPresence } from '../discord/util.js';
import { DiscordPresencePlayTime, DiscordPresenceContext, DiscordPresence, ExternalMonitorConstructor, ExternalMonitor, ErrorResult } from '../discord/types.js';
import { EmbeddedSplatNet2Monitor, handleError, ZncNotifications } from './notify.js';
import { EmbeddedSplatNet2Monitor, ZncNotifications } from './notify.js';
import { getPresenceFromUrl } from '../api/znc-proxy.js';
import { ActiveEvent, CurrentUser, Friend, Game, Presence, PresenceState, CoralErrorResponse } from '../api/coral-types.js';
import { ErrorResponse, ResponseSymbol } from '../api/util.js';
@ -12,6 +12,7 @@ import { getTitleIdFromEcUrl } from '../index.js';
import { parseLinkHeader } from '../util/http.js';
import { getUserAgent } from '../util/useragent.js';
import { TemporaryErrorSymbol } from '../util/misc.js';
import { handleError } from '../util/errors.js';
const debug = createDebug('nxapi:nso:presence');
const debugEventStream = createDebug('nxapi:nso:presence:sse');

View File

@ -1,7 +1,7 @@
import createDebug from 'debug';
import persist from 'node-persist';
import DiscordRPC from 'discord-rpc';
import { BankaraMatchMode, BankaraMatchSetting, CoopSchedule, CoopSchedule_schedule, CoopSetting, DetailVotingStatusResult, FestMatchSetting, FestState, FestTeamRole, FestTeam_schedule, FestTeam_votingStatus, Fest_schedule, FriendListResult, FriendOnlineState, GraphQLSuccessResponse, LeagueMatchSetting, RegularMatchSetting, StageScheduleResult, VsSchedule_bankara, VsSchedule_fest, VsSchedule_league, VsSchedule_regular, VsSchedule_xMatch, XMatchSetting } from 'splatnet3-types/splatnet3';
import { BankaraMatchMode, BankaraMatchSetting, CoopRule, CoopSchedule_schedule, CoopSetting_schedule, DetailVotingStatusResult, FestMatchSetting, FestTeam_schedule, FestTeam_votingStatus, Fest_schedule, FriendListResult, FriendOnlineState, GraphQLSuccessResponse, LeagueMatchSetting, RegularMatchSetting, StageScheduleResult, VsSchedule_bankara, VsSchedule_fest, VsSchedule_league, VsSchedule_regular, VsSchedule_xMatch, XMatchSetting } from 'splatnet3-types/splatnet3';
import { Game } from '../../api/coral-types.js';
import SplatNet3Api from '../../api/splatnet3.js';
import { DiscordPresenceExternalMonitorsConfiguration } from '../../app/common/types.js';
@ -12,6 +12,7 @@ import { EmbeddedLoop, LoopResult } from '../../util/loop.js';
import { ArgumentsCamelCase } from '../../util/yargs.js';
import { DiscordPresenceContext, ErrorResult } from '../types.js';
import { product } from '../../util/product.js';
import StageScheduleQuery_730cd98 from 'splatnet3-types/graphql/730cd98e84f1030d3e9ac86b6f1aae13';
const debug = createDebug('nxapi:discord:splatnet3');
@ -34,6 +35,7 @@ export default class SplatNet3Monitor extends EmbeddedLoop {
x_schedule: VsSchedule_xMatch | null = null;
coop_regular_schedule: CoopSchedule_schedule | null = null;
coop_big_run_schedule: CoopSchedule_schedule | null = null;
coop_team_contest_schedule: CoopSchedule_schedule | null = null;
fest: Fest_schedule | null = null;
fest_team_voting_status: FestTeam_votingStatus | null = null;
fest_team: FestTeam_schedule | null = null;
@ -129,10 +131,12 @@ export default class SplatNet3Monitor extends EmbeddedLoop {
this.anarchy_schedule = this.getSchedule(this.cached_schedules?.data.bankaraSchedules.nodes ?? []);
this.fest_schedule = this.getSchedule(this.cached_schedules?.data.festSchedules.nodes ?? []);
this.league_schedule = this.getSchedule(this.cached_schedules?.data.leagueSchedules.nodes ?? []);
this.league_schedule = this.cached_schedules?.data && 'leagueSchedules' in this.cached_schedules.data ?
this.getSchedule((this.cached_schedules.data as StageScheduleQuery_730cd98).leagueSchedules.nodes ?? []) : null;
this.x_schedule = this.getSchedule(this.cached_schedules?.data.xSchedules.nodes ?? []);
this.coop_regular_schedule = this.getSchedule(this.cached_schedules?.data.coopGroupingSchedule.regularSchedules.nodes ?? []);
this.coop_big_run_schedule = this.getSchedule(this.cached_schedules?.data.coopGroupingSchedule.bigRunSchedules.nodes ?? []);
this.coop_team_contest_schedule = this.getSchedule(this.cached_schedules?.data.coopGroupingSchedule.teamContestSchedules.nodes ?? []);
this.fest = this.cached_schedules?.data.currentFest ?? null;
// Identify the user by their icon as the vote list doesn't have friend IDs
@ -223,7 +227,7 @@ interface PresenceUrlResponse {
splatoon3_vs_setting?:
RegularMatchSetting | BankaraMatchSetting | FestMatchSetting |
LeagueMatchSetting | XMatchSetting | null;
splatoon3_coop_setting?: CoopSetting | null;
splatoon3_coop_setting?: CoopSetting_schedule | null;
splatoon3_fest?: Fest_schedule | null;
}
@ -265,7 +269,9 @@ export function callback(activity: DiscordRPC.Presence, game: Game, context?: Di
friend.vsMode.id === 'VnNNb2RlLTUx' ?
monitor.anarchy_schedule?.bankaraMatchSettings?.find(s => s.mode === BankaraMatchMode.OPEN) :
null :
friend.vsMode.mode === 'FEST' ? monitor.fest_schedule?.festMatchSetting :
friend.vsMode.mode === 'FEST' ?
friend.vsMode.id === 'VnNNb2RlLTg=' ? null :
monitor.fest_schedule?.festMatchSetting :
friend.vsMode.mode === 'LEAGUE' ? monitor.league_schedule?.leagueMatchSetting :
friend.vsMode.mode === 'X_MATCH' ? monitor.x_schedule?.xMatchSetting :
null;
@ -277,24 +283,38 @@ export function callback(activity: DiscordRPC.Presence, game: Game, context?: Di
(friend.vsMode.mode !== 'FEST' && setting ? ' - ' + setting.vsRule.name : '') +
(friend.onlineState === FriendOnlineState.VS_MODE_MATCHING ? ' (matching)' : '');
if (friend.vsMode.id === 'VnNNb2RlLTg=' && fest) {
const tricolour_stage_image = new URL(fest.tricolorStage.image.url);
const match = tricolour_stage_image.pathname.match(/^\/resources\/prod\/(.+)$/);
const proxy_stage_image =
tricolour_stage_image.host === 'splatoon3.ink' ? tricolour_stage_image.href :
match ? 'https://splatoon3.ink/assets/splatnet/' + match[1] :
null;
if (proxy_stage_image) {
activity.largeImageKey = proxy_stage_image;
activity.largeImageText = fest.tricolorStage.name +
' | ' + product;
}
}
if (setting) {
// In the second half the player may be in a Tricolour battle if either:
// the player is on the defending team and joins Splatfest Battle (Open) or
// the player is on the attacking team and joins Tricolour Battle
// const possibly_tricolour = fest?.state === FestState.SECOND_HALF && (
// (friend.vsMode?.id === 'VnNNb2RlLTY=' && fest_team?.role === FestTeamRole.DEFENSE) ||
// (friend.vsMode?.id === 'VnNNb2RlLTg=')
// (friend.vsMode.id === 'VnNNb2RlLTY=' && fest_team?.role === FestTeamRole.DEFENSE) ||
// (friend.vsMode.id === 'VnNNb2RlLTg=')
// );
const possibly_tricolour = friend.vsMode?.id === 'VnNNb2RlLTg=';
activity.largeImageKey = 'https://fancy.org.uk/api/nxapi/s3/image?' + new URLSearchParams({
a: setting.vsStages[0].id,
b: setting.vsStages[1].id,
...(possibly_tricolour ? {t: fest?.tricolorStage.id} : {}),
// ...(possibly_tricolour ? {t: fest?.tricolorStage.id} : {}),
v: '2022092400',
}).toString();
activity.largeImageText = setting.vsStages.map(s => s.name).join('/') +
(possibly_tricolour ? '/' + fest?.tricolorStage.name : '') +
// (possibly_tricolour ? '/' + fest?.tricolorStage.name : '') +
' | ' + product;
}
@ -321,9 +341,9 @@ export function callback(activity: DiscordRPC.Presence, game: Game, context?: Di
presence_proxy_data && 'splatoon3_coop_setting' in presence_proxy_data ?
presence_proxy_data.splatoon3_coop_setting :
monitor ?
friend.coopRule === 'BIG_RUN' ?
monitor.coop_big_run_schedule?.setting :
monitor.coop_regular_schedule?.setting :
friend.coopRule === CoopRule.BIG_RUN ? monitor.coop_big_run_schedule?.setting :
friend.coopRule === CoopRule.TEAM_CONTEST ? monitor.coop_team_contest_schedule?.setting :
monitor.coop_regular_schedule?.setting :
null;
if (coop_setting) {
@ -341,4 +361,8 @@ export function callback(activity: DiscordRPC.Presence, game: Game, context?: Di
}
}
}
if (friend.onlineState === FriendOnlineState.MINI_GAME_PLAYING) {
activity.details = 'Tableturf Battle';
}
}

View File

@ -2,3 +2,4 @@ export * as nintendo from './nintendo.js';
export * as mojang from './mojang.js';
export * as capcom from './capcom.js';
export * as the_pokemon_company from './the-pokémon-company.js';
export * as thatgamecompany from './thatgamecompany.js';

View File

@ -138,6 +138,18 @@ export const titles: Title[] = [
client: '950907272438104064',
largeImageText: 'SEGA Mega Drive',
},
{
// Game Boy - Nintendo Switch Online
id: '0100c62011050000',
client: '950907272438104064',
largeImageText: 'Game Boy',
},
{
// Game Boy Advance - Nintendo Switch Online
id: '010012f017576000',
client: '950907272438104064',
largeImageText: 'Game Boy Advance',
},
{
// Animal Crossing: New Horizons
@ -1148,4 +1160,20 @@ export const titles: Title[] = [
showPlayingOnline: true,
showActiveEvent: true,
},
{
// Fire Emblem Engage
id: '0100a6301214e000',
client: '1037891927430922391',
showPlayingOnline: true,
showActiveEvent: true,
},
{
// Kirby's Return to Dream Land Deluxe
id: '01006b601380e000',
client: '1037892083748446278',
showPlayingOnline: true,
showActiveEvent: true,
},
];

View File

@ -0,0 +1,11 @@
import { Title } from '../types.js';
export const titles: Title[] = [
{
// Sky: Children of the Light
id: '0100c52011460000',
client: '1080136394288144465',
showPlayingOnline: true,
showActiveEvent: true,
},
];

54
src/util/errors.ts Normal file
View File

@ -0,0 +1,54 @@
import createDebug from 'debug';
import { AbortError } from 'node-fetch';
import Loop, { LoopResult } from './loop.js';
import { CoralErrorResponse } from '../api/coral-types.js';
import { ErrorResponse } from '../api/util.js';
import { TemporaryErrorSymbol } from './misc.js';
const debug = createDebug('nxapi:util:errors');
export const temporary_system_errors = {
'ETIMEDOUT': 'request timed out',
'ENOTFOUND': null,
'EAI_AGAIN': 'name resolution failed',
'ECONNRESET': 'connection reset',
};
export const temporary_http_errors = [
502, // Bad Gateway
503, // Service Unavailable
504, // Gateway Timeout
// Non-standard Cloudflare status codes
521, // Web Server Is Down
522, // Connection Timed Out
523, // Origin Is Unreachable
524, // A Timeout Occurred
530, // Unknown (1xxx error)
];
export async function handleError(
err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException,
loop: Loop,
): Promise<LoopResult> {
if (TemporaryErrorSymbol in err && err[TemporaryErrorSymbol]) {
debug('Temporary error, waiting %ds before retrying', loop.update_interval, err);
return LoopResult.OK;
} else if (err instanceof AbortError) {
debug('Request aborted (timeout?), waiting %ds before retrying', loop.update_interval, err);
return LoopResult.OK;
} else if ('code' in err && (err as any).type === 'system' && err.code && err.code in temporary_system_errors) {
const desc = temporary_system_errors[err.code as keyof typeof temporary_system_errors];
debug('Request error%s, waiting %ds before retrying', desc ? ' - ' + desc : '', loop.update_interval, err);
return LoopResult.OK;
} else if (err instanceof ErrorResponse && temporary_http_errors.includes(err.response.status)) {
debug('Request error - HTTP %s (%s), waiting %ds before retrying',
err.response.status, err.response.statusText, loop.update_interval, err);
return LoopResult.OK;
} else {
throw err;
}
}

View File

@ -34,7 +34,7 @@ export function timeoutSignal(ms = 60 * 1000) {
const timeout = setTimeout(() => {
const err = new Error('Timeout');
Object.assign(err, TemporaryErrorSymbol, true);
Object.defineProperty(err, TemporaryErrorSymbol, {value: true});
controller.abort(err);
}, ms);