mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-25 07:27:19 -05:00
Merge branch 'main' into i18n
# Conflicts: # src/app/browser/main/discord.tsx
This commit is contained in:
commit
cde3f4c7a3
18
README.md
18
README.md
|
|
@ -254,13 +254,19 @@ When using nxapi as a TypeScript/JavaScript library, the `addUserAgent` function
|
|||
import { addUserAgent } from 'nxapi';
|
||||
|
||||
addUserAgent('your-script/1.0.0 (+https://github.com/...)');
|
||||
```
|
||||
|
||||
// This could also be read from a package.json file
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises':
|
||||
const pkg = JSON.parse(await readFile(resolve(fileURLToPath(import.meta.url), '..', 'package.json'), 'utf-8'));
|
||||
addUserAgent(pkg.name + '/' + pkg.version + ' (+' + pkg.repository.url + ')');
|
||||
The `addUserAgentFromPackageJson` function can be used to add data from a package.json file.
|
||||
|
||||
```ts
|
||||
import { addUserAgentFromPackageJson } from 'nxapi';
|
||||
|
||||
await addUserAgentFromPackageJson(new URL('../package.json', import.meta.url));
|
||||
await addUserAgentFromPackageJson(path.resolve(fileURLToString(import.meta.url), '..', 'package.json'));
|
||||
// adds "test-package/0.1.0 (+https://github.com/ghost/example.git)"
|
||||
|
||||
await addUserAgentFromPackageJson(new URL('../package.json', import.meta.url), 'additional information');
|
||||
// adds "test-package/0.1.0 (+https://github.com/ghost/example.git; additional information)"
|
||||
```
|
||||
|
||||
### Usage as a TypeScript/JavaScript library
|
||||
|
|
|
|||
|
|
@ -40,18 +40,18 @@ services:
|
|||
|
||||
presence-server:
|
||||
build: .
|
||||
command: presence-server --listen \[::]:80 --splatnet3
|
||||
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-fest-votes
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- presence-server
|
||||
labels:
|
||||
traefik.enable: true
|
||||
traefik.http.routers.nxapi-presence.entrypoints: websecure
|
||||
traefik.http.routers.nxapi-presence.rule: Host(`${TRAEFIK_HOST:-nxapi.ta.fancy.org.uk}`) && (Path(`/api/presence`) || PathPrefix(`/api/presence/`))
|
||||
traefik.http.routers.nxapi-presence.rule: Host(`${TRAEFIK_HOST:-nxapi.ta.fancy.org.uk}`) && (Path(`/api/presence`) || PathPrefix(`/api/presence/`) || PathPrefix(`/api/splatnet3/resources/`))
|
||||
traefik.http.routers.nxapi-presence.tls: true
|
||||
traefik.http.services.nxapi-presence.loadbalancer.server.port: 80
|
||||
environment:
|
||||
DEBUG: '*,-express:*'
|
||||
DEBUG: '*,-express:*,-send'
|
||||
ZNC_PROXY_URL: http://znc-proxy/api/znc
|
||||
NXAPI_PRESENCE_SERVER_USER: ${NXAPI_PRESENCE_SERVER_USER:-}
|
||||
NXAPI_PRESENCE_SERVER_SPLATNET3_PROXY_URL: http://presence-splatnet3-proxy/api/splatnet3-presence
|
||||
|
|
@ -60,7 +60,7 @@ services:
|
|||
|
||||
presence-splatnet3-proxy:
|
||||
build: .
|
||||
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-proxy
|
||||
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-proxy --splatnet3-record-fest-votes
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- presence-server
|
||||
|
|
|
|||
|
|
@ -151,6 +151,10 @@ try {
|
|||
|
||||
This function is used to set the user agent string to use for non-Nintendo API requests. Any project using nxapi (including as a dependency of another project) must call this function with an appropriate user agent string segment. See [user agent strings](../../README.md#user-agent-strings).
|
||||
|
||||
#### `addUserAgentFromPackageJson`
|
||||
|
||||
This function is used to set the user agent string to use for non-Nintendo API requests using data from a package.json file. A string, URL object or the package.json data can be provided, as well as optional additional data. If a string/URL is provided this will return a Promise that will be resolved once the user agent is updated. See [user agent strings](../../README.md#user-agent-strings).
|
||||
|
||||
#### `version`
|
||||
|
||||
nxapi's version number.
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"require_version": [],
|
||||
"coral": {
|
||||
"znca_version": "2.4.0"
|
||||
"znca_version": "2.5.0"
|
||||
},
|
||||
"coral_auth": {
|
||||
"default": "imink",
|
||||
|
|
@ -10,15 +10,15 @@
|
|||
"imink": {}
|
||||
},
|
||||
"moon": {
|
||||
"znma_version": "1.17.0",
|
||||
"znma_build": "261"
|
||||
"znma_version": "1.18.0",
|
||||
"znma_build": "275"
|
||||
},
|
||||
"coral_gws_nooklink": {
|
||||
"blanco_version": "2.1.1"
|
||||
},
|
||||
"coral_gws_splatnet3": {
|
||||
"app_ver": "3.0.0-2857bc50",
|
||||
"app_ver": "3.0.0-0742bda0",
|
||||
"version": "3.0.0",
|
||||
"revision": "2857bc50653d316cb69f017b2eef24d2ae56a1b7"
|
||||
"revision": "0742bda0f28edfcda33ec743b8afc4a95700e27d"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -320,7 +327,11 @@ export default class CoralApi {
|
|||
if (!config) throw new Error('Remote configuration prevents Coral authentication');
|
||||
const znca_useragent = `com.nintendo.znca/${config.znca_version}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`;
|
||||
|
||||
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');
|
||||
|
||||
|
|
|
|||
58
src/api/f.ts
58
src/api/f.ts
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str
|
|||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Platform': 'Android',
|
||||
'X-ProductVersion': '2.0.0',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'NASDKAPI; Android',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export default function DiscordPresenceSource(props: {
|
|||
return <TouchableOpacity onPress={() => ipc.showDiscordModal()}>
|
||||
<View style={[styles.discord, !props.source ? styles.discordInactive : null]}>
|
||||
{renderDiscordPresenceSource(props.source)}
|
||||
{props.presence && props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
|
||||
{props.presence || props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
|
||||
</View>
|
||||
</TouchableOpacity>;
|
||||
}
|
||||
|
|
@ -86,28 +86,37 @@ function DiscordPresenceInactive() {
|
|||
}
|
||||
|
||||
function DiscordPresence(props: {
|
||||
presence: DiscordPresence;
|
||||
user: User;
|
||||
presence: DiscordPresence | null;
|
||||
user: User | null;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
|
||||
|
||||
const large_image_url = props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
|
||||
const large_image_url = props.presence ? props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
|
||||
'https://cdn.discordapp.com/app-assets/' + props.presence.id + '/' +
|
||||
props.presence.activity.largeImageKey + '.png' :
|
||||
props.presence.activity.largeImageKey;
|
||||
const user_image_url = 'https://cdn.discordapp.com/avatars/' + props.user.id + '/' + props.user.avatar + '.png';
|
||||
props.presence.activity.largeImageKey : undefined;
|
||||
|
||||
return <>
|
||||
<View style={styles.discordPresence}>
|
||||
const user_image_url = props.user ?
|
||||
props.user.avatar ? 'https://cdn.discordapp.com/avatars/' + props.user.id + '/' + props.user.avatar + '.png' :
|
||||
!props.user.discriminator || props.user.discriminator === '0' ?
|
||||
'https://cdn.discordapp.com/embed/avatars/' + ((parseInt(props.user.id) >> 22) % 5) + '.png' :
|
||||
'https://cdn.discordapp.com/embed/avatars/' + (parseInt(props.user.discriminator) % 5) + '.png' : undefined;
|
||||
|
||||
return <View style={styles.discordPresenceContainer}>
|
||||
{props.presence ? <View style={styles.discordPresence}>
|
||||
<Image source={{uri: large_image_url, width: 18, height: 18}} style={styles.discordPresenceImage} />
|
||||
<Text style={styles.discordPresenceText} numberOfLines={1} ellipsizeMode="tail">{t('discord_playing')}</Text>
|
||||
</View>
|
||||
</View> : null}
|
||||
|
||||
<View style={styles.discordUser}>
|
||||
{props.user ? <View style={styles.discordUser}>
|
||||
<Image source={{uri: user_image_url, width: 18, height: 18}} style={styles.discordUserImage} />
|
||||
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">{props.user.username}#{props.user.discriminator}</Text>
|
||||
</View>
|
||||
</>;
|
||||
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{props.user.username}<Text style={styles.discordUserDiscriminator}>#{props.user.discriminator}</Text>
|
||||
</Text>
|
||||
</View> : <View style={styles.discordUser}>
|
||||
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">Not connected to Discord</Text>
|
||||
</View>}
|
||||
</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
@ -131,8 +140,12 @@ const styles = StyleSheet.create({
|
|||
userSelect: 'all',
|
||||
},
|
||||
|
||||
discordPresenceContainer: {
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
discordPresence: {
|
||||
marginTop: 12,
|
||||
marginTop: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
|
@ -156,4 +169,7 @@ const styles = StyleSheet.create({
|
|||
discordUserText: {
|
||||
color: TEXT_COLOUR_DARK,
|
||||
},
|
||||
discordUserDiscriminator: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -155,8 +155,12 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
|
|||
const users: User[] = [];
|
||||
|
||||
for (const client of await getDiscordRpcClients()) {
|
||||
await client.connect(defaultTitle.client);
|
||||
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
|
||||
try {
|
||||
await client.connect(defaultTitle.client);
|
||||
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export class PresenceMonitorManager {
|
|||
i.presence_user = null;
|
||||
i.user_notifications = false;
|
||||
i.friend_notifications = false;
|
||||
i.discord_preconnect = true;
|
||||
|
||||
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
|
||||
this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null);
|
||||
|
|
@ -91,6 +92,7 @@ export class PresenceMonitorManager {
|
|||
const i = new EmbeddedProxyPresenceMonitor(presence_url);
|
||||
|
||||
i.notifications = this.notifications;
|
||||
i.discord_preconnect = true;
|
||||
|
||||
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
|
||||
this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -755,10 +755,7 @@ class Server extends HttpServer {
|
|||
this.resetAuthTimeout(na_session_token, () => user.data.user.id);
|
||||
}
|
||||
} catch (err) {
|
||||
stream.sendEvent('error', {
|
||||
error: (err as Error).name,
|
||||
error_message: (err as Error).message,
|
||||
});
|
||||
stream.sendErrorEvent(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import * as net from 'node:net';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import createDebug from 'debug';
|
||||
import express, { Request, Response } from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import * as persist from 'node-persist';
|
||||
import { BankaraMatchMode, BankaraMatchSetting_schedule, CoopSetting_schedule, DetailVotingStatusResult, FestMatchSetting_schedule, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, FriendListResult, FriendOnlineState, Friend_friendList, GraphQLSuccessResponse, LeagueMatchSetting_schedule, RegularMatchSetting_schedule, StageScheduleResult, VsMode, XMatchSetting_schedule } from 'splatnet3-types/splatnet3';
|
||||
import mkdirp from 'mkdirp';
|
||||
import { BankaraMatchMode, BankaraMatchSetting_schedule, CoopRule, CoopSetting_schedule, DetailFestRecordDetailResult, DetailVotingStatusResult, FestMatchSetting_schedule, FestRecordResult, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, FriendListResult, FriendOnlineState, Friend_friendList, GraphQLSuccessResponse, KnownRequestId, LeagueMatchSetting_schedule, RegularMatchSetting_schedule, StageScheduleResult, VsMode, XMatchSetting_schedule } from 'splatnet3-types/splatnet3';
|
||||
import type { Arguments as ParentArguments } from '../cli.js';
|
||||
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
|
||||
import { initStorage } from '../util/storage.js';
|
||||
|
|
@ -14,8 +17,8 @@ import { product, version } from '../util/product.js';
|
|||
import Users, { CoralUser } from '../common/users.js';
|
||||
import { Friend } from '../api/coral-types.js';
|
||||
import { getBulletToken, SavedBulletToken } from '../common/auth/splatnet3.js';
|
||||
import SplatNet3Api from '../api/splatnet3.js';
|
||||
import { ErrorResponse } from '../api/util.js';
|
||||
import SplatNet3Api, { PersistedQueryResult, RequestIdSymbol } from '../api/splatnet3.js';
|
||||
import { ErrorResponse, ResponseSymbol } 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';
|
||||
|
|
@ -47,6 +50,21 @@ interface TitleResult {
|
|||
since: string;
|
||||
}
|
||||
|
||||
interface FestVotingStatusRecord {
|
||||
result: Exclude<DetailVotingStatusResult['fest'], null>;
|
||||
query: KnownRequestId;
|
||||
app_version: string;
|
||||
be_version: string | null;
|
||||
|
||||
friends: {
|
||||
result: FriendListResult['friends'];
|
||||
query: KnownRequestId;
|
||||
be_version: string | null;
|
||||
};
|
||||
|
||||
fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null;
|
||||
}
|
||||
|
||||
export const command = 'presence-server';
|
||||
export const desc = 'Starts a HTTP server to fetch presence data from Coral and SplatNet 3';
|
||||
|
||||
|
|
@ -78,6 +96,14 @@ export function builder(yargs: Argv<ParentArguments>) {
|
|||
describe: 'SplatNet 3 proxy URL',
|
||||
type: 'string',
|
||||
default: process.env.NXAPI_PRESENCE_SERVER_SPLATNET3_PROXY_URL,
|
||||
}).option('splatnet3-fest-votes', {
|
||||
describe: 'Record Splatoon 3 fest vote history',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('splatnet3-record-fest-votes', {
|
||||
describe: 'Record Splatoon 3 fest vote history',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('update-interval', {
|
||||
describe: 'Max. update interval in seconds',
|
||||
type: 'number',
|
||||
|
|
@ -91,6 +117,8 @@ export function builder(yargs: Argv<ParentArguments>) {
|
|||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
const ResourceUrlMapSymbol = Symbol('ResourceUrls');
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
addCliFeatureUserAgent('presence-server');
|
||||
|
||||
|
|
@ -113,10 +141,23 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
SplatNet3ApiUser.create(storage, token, argv.zncProxyUrl);
|
||||
}) : null;
|
||||
|
||||
const server = new Server(storage, coral_users, splatnet3_users, user_naids);
|
||||
const image_proxy_path = {
|
||||
baas: path.join(argv.dataPath, 'presence-server-resources', 'baas'),
|
||||
atum: path.join(argv.dataPath, 'presence-server-resources', 'atum'),
|
||||
splatnet3: path.join(argv.dataPath, 'presence-server-resources', 'splatnet3'),
|
||||
};
|
||||
|
||||
const server = new Server(storage, coral_users, splatnet3_users, user_naids, image_proxy_path);
|
||||
|
||||
server.allow_all_users = argv.allowAllUsers;
|
||||
server.enable_splatnet3_proxy = argv.splatnet3Proxy;
|
||||
server.record_fest_votes = argv.splatnet3FestVotes || argv.splatnet3RecordFestVotes ? {
|
||||
path: path.join(argv.dataPath, 'presence-server'),
|
||||
read: argv.splatnet3FestVotes,
|
||||
write: argv.splatnet3RecordFestVotes,
|
||||
} : null;
|
||||
server.update_interval = argv.updateInterval * 1000;
|
||||
|
||||
const app = server.app;
|
||||
|
||||
for (const address of argv.listen) {
|
||||
|
|
@ -127,13 +168,62 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
console.log('Listening on %s, port %d', address.address, address.port);
|
||||
});
|
||||
}
|
||||
|
||||
if (argv.splatnet3RecordFestVotes) {
|
||||
const update_interval_fest_voting_status_record = 60 * 60 * 1000; // 60 minutes
|
||||
|
||||
const recordFestVotes = async (is_force_early = false) => {
|
||||
const users = await Promise.all(user_naids.map(id => server.getSplatNet3User(id)));
|
||||
|
||||
debug('Checking for new fest votes to record', is_force_early);
|
||||
|
||||
let fest_ending: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null = null;
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const fest = await user.getCurrentFest();
|
||||
|
||||
if (is_force_early) user.updated.fest_vote_status = null;
|
||||
|
||||
// Fetching current fest vote data will record any new data
|
||||
await user.getCurrentFestVotes();
|
||||
|
||||
if (fest && (!fest_ending ||
|
||||
new Date(fest_ending.endTime).getTime() > new Date(fest.endTime).getTime()
|
||||
)) {
|
||||
fest_ending = fest;
|
||||
}
|
||||
} catch (err) {
|
||||
debug('Error fetching current fest voting status for recording');
|
||||
}
|
||||
}
|
||||
|
||||
const time_until_fest_ends_ms = fest_ending ? new Date(fest_ending.endTime).getTime() - Date.now() : null;
|
||||
const update_interval = time_until_fest_ends_ms && time_until_fest_ends_ms > 60 * 1000 ?
|
||||
Math.min(time_until_fest_ends_ms - 60 * 1000, update_interval_fest_voting_status_record) :
|
||||
update_interval_fest_voting_status_record;
|
||||
|
||||
setTimeout(() => recordFestVotes(update_interval !== update_interval_fest_voting_status_record),
|
||||
update_interval);
|
||||
};
|
||||
|
||||
recordFestVotes();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class SplatNet3User {
|
||||
created_at = Date.now();
|
||||
expires_at = Infinity;
|
||||
|
||||
record_fest_votes: {
|
||||
path: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
} | null = null;
|
||||
|
||||
schedules: GraphQLSuccessResponse<StageScheduleResult> | null = null;
|
||||
fest_records: GraphQLSuccessResponse<FestRecordResult> | null = null;
|
||||
current_fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null = null;
|
||||
fest_vote_status: GraphQLSuccessResponse<DetailVotingStatusResult> | null = null;
|
||||
|
||||
promise = new Map<string, Promise<void>>();
|
||||
|
|
@ -141,6 +231,8 @@ abstract class SplatNet3User {
|
|||
updated = {
|
||||
friends: Date.now(),
|
||||
schedules: null as number | null,
|
||||
fest_records: null as number | null,
|
||||
current_fest: null as number | null,
|
||||
fest_vote_status: null as number | null,
|
||||
};
|
||||
update_interval = 10 * 1000; // 10 seconds
|
||||
|
|
@ -199,15 +291,55 @@ abstract class SplatNet3User {
|
|||
|
||||
abstract getSchedulesData(): Promise<GraphQLSuccessResponse<StageScheduleResult>>;
|
||||
|
||||
async getCurrentFest(): Promise<StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null> {
|
||||
let update_interval = this.update_interval_schedules;
|
||||
|
||||
if (this.schedules && this.schedules.data.currentFest) {
|
||||
const tricolour_open = new Date(this.schedules.data.currentFest.midtermTime).getTime() <= Date.now();
|
||||
const should_refresh_fest = tricolour_open &&
|
||||
![FestState.SECOND_HALF, FestState.CLOSED].includes(this.schedules.data.currentFest.state as FestState);
|
||||
|
||||
if (should_refresh_fest) update_interval = this.update_interval;
|
||||
}
|
||||
|
||||
await this.update('current_fest', async () => {
|
||||
this.current_fest = await this.getCurrentFestData();
|
||||
}, update_interval);
|
||||
|
||||
return this.current_fest;
|
||||
}
|
||||
|
||||
abstract getCurrentFestData(): Promise<StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null>;
|
||||
|
||||
async getCurrentFestVotes(): Promise<DetailVotingStatusResult['fest'] | null> {
|
||||
await this.update('fest_vote_status', async () => {
|
||||
this.fest_vote_status = await this.getCurrentFestVotingStatusData();
|
||||
const fest_vote_status = await this.getCurrentFestVotingStatusData();
|
||||
|
||||
if (fest_vote_status) this.tryRecordFestVotes(fest_vote_status);
|
||||
|
||||
this.fest_vote_status = fest_vote_status;
|
||||
}, this.update_interval_fest_voting_status ?? this.update_interval);
|
||||
|
||||
return this.fest_vote_status?.data.fest ?? null;
|
||||
}
|
||||
|
||||
abstract getCurrentFestVotingStatusData(): Promise<GraphQLSuccessResponse<DetailVotingStatusResult> | null>;
|
||||
|
||||
async tryRecordFestVotes(fest_vote_status: GraphQLSuccessResponse<DetailVotingStatusResult>) {
|
||||
if (this.record_fest_votes?.write && fest_vote_status.data.fest &&
|
||||
JSON.stringify(fest_vote_status?.data) !== JSON.stringify(this.fest_vote_status?.data)
|
||||
) {
|
||||
try {
|
||||
await this.recordFestVotes(fest_vote_status as PersistedQueryResult<DetailVotingStatusResult>);
|
||||
} catch (err) {
|
||||
debug('Error recording updated fest vote data', fest_vote_status.data.fest.id, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async recordFestVotes(fest_vote_status: PersistedQueryResult<DetailVotingStatusResult>) {
|
||||
throw new Error('Cannot record fest vote status when using SplatNet 3 API proxy');
|
||||
}
|
||||
}
|
||||
|
||||
class SplatNet3ApiUser extends SplatNet3User {
|
||||
|
|
@ -227,10 +359,36 @@ class SplatNet3ApiUser extends SplatNet3User {
|
|||
return this.splatnet.getSchedules();
|
||||
}
|
||||
|
||||
async getCurrentFestVotingStatusData() {
|
||||
async getCurrentFestData() {
|
||||
const schedules = await this.getSchedules();
|
||||
return !schedules.currentFest || new Date(schedules.currentFest.endTime).getTime() <= Date.now() ? null :
|
||||
await this.getFestVotingStatusData(schedules.currentFest.id);
|
||||
|
||||
if (schedules.currentFest) {
|
||||
return new Date(schedules.currentFest.endTime).getTime() <= Date.now() ? null : schedules.currentFest;
|
||||
}
|
||||
|
||||
await this.update('fest_records', async () => {
|
||||
this.fest_records = await this.splatnet.getFestRecords();
|
||||
}, this.update_interval_schedules);
|
||||
|
||||
const current_or_upcoming_fest = this.fest_records!.data.festRecords.nodes.find(fest =>
|
||||
new Date(fest.endTime).getTime() >= Date.now());
|
||||
if (!current_or_upcoming_fest) return null;
|
||||
|
||||
const fest_detail = await this.getFestDetailData(current_or_upcoming_fest.id);
|
||||
|
||||
return fest_detail.data.fest;
|
||||
}
|
||||
|
||||
async getFestDetailData(id: string) {
|
||||
return this.current_fest?.id === id ?
|
||||
await this.splatnet.getFestDetailRefetch(id) :
|
||||
await this.splatnet.getFestDetail(id);
|
||||
}
|
||||
|
||||
async getCurrentFestVotingStatusData() {
|
||||
const fest = await this.getCurrentFest();
|
||||
return !fest || new Date(fest.endTime).getTime() <= Date.now() ? null :
|
||||
await this.getFestVotingStatusData(fest.id);
|
||||
}
|
||||
|
||||
async getFestVotingStatusData(id: string) {
|
||||
|
|
@ -239,6 +397,37 @@ class SplatNet3ApiUser extends SplatNet3User {
|
|||
await this.splatnet.getFestVotingStatus(id);
|
||||
}
|
||||
|
||||
async recordFestVotes(result: PersistedQueryResult<DetailVotingStatusResult>) {
|
||||
if (!result.data.fest) return;
|
||||
|
||||
const id_str = Buffer.from(result.data.fest.id, 'base64').toString() || result.data.fest.id;
|
||||
const match = id_str.match(/^Fest-([A-Z]{2}):(([A-Z]+)-(\d+))$/);
|
||||
const id = match ? match[1] + '-' + match[2] : id_str;
|
||||
|
||||
debug('Recording updated fest vote data', id);
|
||||
|
||||
await this.getFriends();
|
||||
const friends = this.friends as PersistedQueryResult<FriendListResult>;
|
||||
|
||||
const record: FestVotingStatusRecord = {
|
||||
result: result.data.fest,
|
||||
query: result[RequestIdSymbol],
|
||||
app_version: this.splatnet.version,
|
||||
be_version: result[ResponseSymbol].headers.get('x-be-version'),
|
||||
|
||||
friends: {
|
||||
result: friends.data.friends,
|
||||
query: friends[RequestIdSymbol],
|
||||
be_version: friends[ResponseSymbol].headers.get('x-be-version'),
|
||||
},
|
||||
|
||||
fest: await this.getCurrentFest(),
|
||||
};
|
||||
|
||||
await mkdirp(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id));
|
||||
await fs.writeFile(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id, Date.now() + '.json'), JSON.stringify(record, null, 4) + '\n');
|
||||
}
|
||||
|
||||
static async create(storage: persist.LocalStorage, token: string, znc_proxy_url?: string) {
|
||||
const {splatnet, data} = await getBulletToken(storage, token, znc_proxy_url, true);
|
||||
|
||||
|
|
@ -276,6 +465,10 @@ class SplatNet3ProxyUser extends SplatNet3User {
|
|||
return this.fetch('/schedules');
|
||||
}
|
||||
|
||||
async getCurrentFestData() {
|
||||
return this.fetch('/fest/current');
|
||||
}
|
||||
|
||||
async getCurrentFestVotingStatusData() {
|
||||
return this.fetch('/fest/current/voting-status');
|
||||
}
|
||||
|
|
@ -310,6 +503,16 @@ class Server extends HttpServer {
|
|||
allow_all_users = false;
|
||||
enable_splatnet3_proxy = false;
|
||||
|
||||
record_fest_votes: {
|
||||
path: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
} | null = null;
|
||||
|
||||
readonly image_proxy_path_baas: string | null = null;
|
||||
readonly image_proxy_path_atum: string | null = null;
|
||||
readonly image_proxy_path_splatnet3: string | null = null;
|
||||
|
||||
update_interval = 30 * 1000;
|
||||
/** Interval coral friends data should be updated if the requested user isn't friends with the authenticated user */
|
||||
update_interval_unknown_friends = 10 * 60 * 1000; // 10 minutes
|
||||
|
|
@ -317,12 +520,14 @@ class Server extends HttpServer {
|
|||
app: express.Express;
|
||||
|
||||
titles = new Map</** NSA ID */ string, [TitleResult | null, /** updated */ number]>();
|
||||
readonly promise_image = new Map<string, Promise<string>>();
|
||||
|
||||
constructor(
|
||||
readonly storage: persist.LocalStorage,
|
||||
readonly coral_users: Users<CoralUser>,
|
||||
readonly splatnet3_users: Users<SplatNet3User> | null,
|
||||
readonly user_ids: string[],
|
||||
image_proxy_path?: {baas?: string; atum?: string; splatnet3?: string;},
|
||||
) {
|
||||
super();
|
||||
|
||||
|
|
@ -346,9 +551,20 @@ class Server extends HttpServer {
|
|||
this.handleAllUsersRequest(req, res)));
|
||||
app.get('/api/presence/:user', this.createApiRequestHandler((req, res) =>
|
||||
this.handlePresenceRequest(req, res, req.params.user)));
|
||||
app.get('/api/presence/:user/splatoon3-fest-votes', this.createApiRequestHandler((req, res) =>
|
||||
this.handleUserFestVotingStatusHistoryRequest(req, res, req.params.user)));
|
||||
app.get('/api/presence/:user/events', this.createApiRequestHandler((req, res) =>
|
||||
this.handlePresenceStreamRequest(req, res, req.params.user)));
|
||||
|
||||
if (image_proxy_path?.baas) {
|
||||
this.image_proxy_path_baas = image_proxy_path.baas;
|
||||
app.use('/api/presence/resources/baas', express.static(this.image_proxy_path_baas, {redirect: false}));
|
||||
}
|
||||
if (image_proxy_path?.atum) {
|
||||
this.image_proxy_path_atum = image_proxy_path.atum;
|
||||
app.use('/api/presence/resources/atum', express.static(this.image_proxy_path_atum, {redirect: false}));
|
||||
}
|
||||
|
||||
app.use('/api/splatnet3-presence', (req, res, next) => {
|
||||
console.log('[%s] [splatnet3 proxy] %s %s HTTP/%s from %s, port %d%s, %s',
|
||||
new Date(), req.method, req.url, req.httpVersion,
|
||||
|
|
@ -367,12 +583,52 @@ class Server extends HttpServer {
|
|||
this.handleSplatNet3ProxyFriends(req, res)));
|
||||
app.get('/api/splatnet3-presence/schedules', this.createApiRequestHandler((req, res) =>
|
||||
this.handleSplatNet3ProxySchedules(req, res)));
|
||||
app.get('/api/splatnet3-presence/fest/current', this.createApiRequestHandler((req, res) =>
|
||||
this.handleSplatNet3ProxyCurrentFest(req, res)));
|
||||
app.get('/api/splatnet3-presence/fest/current/voting-status', this.createApiRequestHandler((req, res) =>
|
||||
this.handleSplatNet3ProxyCurrentFestVotingStatus(req, res)));
|
||||
|
||||
app.use('/api/splatnet3', (req, res, next) => {
|
||||
console.log('[%s] [splatnet3] %s %s HTTP/%s from %s, port %d%s, %s',
|
||||
new Date(), req.method, req.url, req.httpVersion,
|
||||
req.socket.remoteAddress, req.socket.remotePort,
|
||||
req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '',
|
||||
req.headers['user-agent']);
|
||||
|
||||
res.setHeader('Server', product + ' presence-server splatnet3-proxy');
|
||||
res.setHeader('X-Server', product + ' presence-server splatnet3-proxy');
|
||||
res.setHeader('X-Served-By', os.hostname());
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
if (image_proxy_path?.splatnet3) {
|
||||
this.image_proxy_path_splatnet3 = image_proxy_path.splatnet3;
|
||||
app.use('/api/splatnet3/resources', express.static(this.image_proxy_path_splatnet3, {redirect: false}));
|
||||
}
|
||||
}
|
||||
|
||||
protected encodeJsonForResponse(data: unknown, space?: number) {
|
||||
return JSON.stringify(data, replacer, space);
|
||||
return JSON.stringify(data, (key: string, value: unknown) => replacer(key, value, data), space);
|
||||
}
|
||||
|
||||
async getCoralUser(naid: string) {
|
||||
const token = await this.storage.getItem('NintendoAccountToken.' + naid);
|
||||
const user = await this.coral_users.get(token);
|
||||
user.update_interval = this.update_interval;
|
||||
return user;
|
||||
}
|
||||
|
||||
async getSplatNet3User(naid: string) {
|
||||
const token = await this.storage.getItem('NintendoAccountToken.' + naid);
|
||||
return this.getSplatNet3UserBySessionToken(token);
|
||||
}
|
||||
|
||||
async getSplatNet3UserBySessionToken(token: string) {
|
||||
const user = await this.splatnet3_users!.get(token);
|
||||
user.record_fest_votes = this.record_fest_votes;
|
||||
user.update_interval = this.update_interval;
|
||||
return user;
|
||||
}
|
||||
|
||||
async handleAllUsersRequest(req: Request, res: Response) {
|
||||
|
|
@ -384,12 +640,7 @@ class Server extends HttpServer {
|
|||
|
||||
const result: AllUsersResult[] = [];
|
||||
|
||||
const users = await Promise.all(this.user_ids.map(async id => {
|
||||
const token = await this.storage.getItem('NintendoAccountToken.' + id);
|
||||
const user = await this.coral_users.get(token);
|
||||
user.update_interval = this.update_interval;
|
||||
return user;
|
||||
}));
|
||||
const users = await Promise.all(this.user_ids.map(id => this.getCoralUser(id)));
|
||||
|
||||
for (const user of users) {
|
||||
const friends = await user.getFriends();
|
||||
|
|
@ -415,12 +666,7 @@ class Server extends HttpServer {
|
|||
}
|
||||
|
||||
if (this.splatnet3_users && include_splatnet3) {
|
||||
const users = await Promise.all(this.user_ids.map(async id => {
|
||||
const token = await this.storage.getItem('NintendoAccountToken.' + id);
|
||||
const user = await this.splatnet3_users!.get(token);
|
||||
user.update_interval = this.update_interval;
|
||||
return user;
|
||||
}));
|
||||
const users = await Promise.all(this.user_ids.map(id => this.getSplatNet3User(id)));
|
||||
|
||||
for (const user of users) {
|
||||
const friends = await user.getFriends();
|
||||
|
|
@ -473,7 +719,9 @@ class Server extends HttpServer {
|
|||
|
||||
result.sort((a, b) => b.presence.updatedAt - a.presence.updatedAt);
|
||||
|
||||
return {result};
|
||||
const images = await this.downloadImages(result, this.getResourceBaseUrls(req));
|
||||
|
||||
return {result, [ResourceUrlMapSymbol]: images};
|
||||
}
|
||||
|
||||
async handlePresenceRequest(req: Request, res: Response | null, presence_user_nsaid: string, is_stream = false) {
|
||||
|
|
@ -523,14 +771,14 @@ class Server extends HttpServer {
|
|||
};
|
||||
|
||||
if (this.splatnet3_users && include_splatnet3) {
|
||||
const token = await this.storage.getItem('NintendoAccountToken.' + user_naid);
|
||||
const user = await this.splatnet3_users!.get(token);
|
||||
user.update_interval = this.update_interval;
|
||||
const user = await this.getSplatNet3User(user_naid);
|
||||
|
||||
await this.handleSplatoon3Presence(friend, user, response);
|
||||
}
|
||||
|
||||
return response;
|
||||
const images = await this.downloadImages(response, this.getResourceBaseUrls(req));
|
||||
|
||||
return {...response, [ResourceUrlMapSymbol]: images};
|
||||
}
|
||||
|
||||
getTitleResult(friend: Friend, updated: number, req: Request) {
|
||||
|
|
@ -546,7 +794,7 @@ class Server extends HttpServer {
|
|||
name: game.name,
|
||||
image_url: game.imageUri,
|
||||
url: 'https://fancy.org.uk/api/nxapi/title/' + encodeURIComponent(id) + '/redirect?source=' +
|
||||
encodeURIComponent('nxapi-' + version + '-presenceserver-' + req.headers.host),
|
||||
encodeURIComponent('nxapi-' + version + '-presenceserver/' + req.headers.host),
|
||||
since: new Date(Math.min(Date.now(), friend.presence.updatedAt * 1000)).toISOString(),
|
||||
} : null;
|
||||
|
||||
|
|
@ -576,38 +824,13 @@ class Server extends HttpServer {
|
|||
response.splatoon3 = friend;
|
||||
|
||||
if (fest_vote_status) {
|
||||
const schedules = await user.getSchedules();
|
||||
const fest = await user.getCurrentFest();
|
||||
|
||||
for (const team of fest_vote_status.teams) {
|
||||
const schedule_team = schedules.currentFest?.teams.find(t => t.id === team.id);
|
||||
if (!schedule_team || !team.votes || !team.preVotes) continue; // Shouldn't ever happen
|
||||
const fest_team = this.getFestTeamVotingStatus(fest_vote_status, fest, friend);
|
||||
|
||||
for (const player of team.votes.nodes) {
|
||||
if (player.userIcon.url !== friend.userIcon.url) continue;
|
||||
|
||||
response.splatoon3_fest_team = {
|
||||
...createFestScheduleTeam(schedule_team, FestVoteState.VOTED),
|
||||
...createFestVoteTeam(team, FestVoteState.VOTED),
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.splatoon3_fest_team) break;
|
||||
|
||||
for (const player of team.preVotes.nodes) {
|
||||
if (player.userIcon.url !== friend.userIcon.url) continue;
|
||||
|
||||
response.splatoon3_fest_team = {
|
||||
...createFestScheduleTeam(schedule_team, FestVoteState.PRE_VOTED),
|
||||
...createFestVoteTeam(team, FestVoteState.PRE_VOTED),
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.splatoon3_fest_team) break;
|
||||
}
|
||||
|
||||
if (!response.splatoon3_fest_team && fest_vote_status.undecidedVotes) {
|
||||
if (fest_team) {
|
||||
response.splatoon3_fest_team = fest_team;
|
||||
} else if (fest_vote_status.undecidedVotes) {
|
||||
response.splatoon3_fest_team = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -635,8 +858,9 @@ class Server extends HttpServer {
|
|||
friend.onlineState === FriendOnlineState.COOP_MODE_FIGHTING
|
||||
) {
|
||||
const schedules = await user.getSchedules();
|
||||
const coop_schedules = friend.coopRule === 'BIG_RUN' ?
|
||||
schedules.coopGroupingSchedule.bigRunSchedules :
|
||||
const coop_schedules =
|
||||
friend.coopRule === CoopRule.BIG_RUN ? schedules.coopGroupingSchedule.bigRunSchedules :
|
||||
friend.coopRule === CoopRule.TEAM_CONTEST ? schedules.coopGroupingSchedule.teamContestSchedules :
|
||||
schedules.coopGroupingSchedule.regularSchedules;
|
||||
const coop_setting = getSchedule(coop_schedules)?.setting;
|
||||
|
||||
|
|
@ -644,6 +868,37 @@ class Server extends HttpServer {
|
|||
}
|
||||
}
|
||||
|
||||
getFestTeamVotingStatus(
|
||||
fest_vote_status: Exclude<DetailVotingStatusResult['fest'], null>,
|
||||
fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null,
|
||||
friend: Friend_friendList,
|
||||
) {
|
||||
for (const team of fest_vote_status.teams) {
|
||||
const schedule_or_detail_team = fest?.teams.find(t => t.id === team.id);
|
||||
if (!schedule_or_detail_team || !team.votes || !team.preVotes) continue;
|
||||
|
||||
for (const player of team.votes.nodes) {
|
||||
if (player.userIcon.url !== friend.userIcon.url) continue;
|
||||
|
||||
return {
|
||||
...createFestScheduleTeam(schedule_or_detail_team, FestVoteState.VOTED),
|
||||
...createFestVoteTeam(team, FestVoteState.VOTED),
|
||||
};
|
||||
}
|
||||
|
||||
for (const player of team.preVotes.nodes) {
|
||||
if (player.userIcon.url !== friend.userIcon.url) continue;
|
||||
|
||||
return {
|
||||
...createFestScheduleTeam(schedule_or_detail_team, FestVoteState.PRE_VOTED),
|
||||
...createFestVoteTeam(team, FestVoteState.PRE_VOTED),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getSettingForVsMode(schedules: StageScheduleResult, vs_mode: Pick<VsMode, 'id' | 'mode'>) {
|
||||
if (vs_mode.mode === 'REGULAR') {
|
||||
return getSchedule(schedules.regularSchedules)?.regularMatchSetting;
|
||||
|
|
@ -669,6 +924,111 @@ class Server extends HttpServer {
|
|||
return null;
|
||||
}
|
||||
|
||||
async handleUserFestVotingStatusHistoryRequest(req: Request, res: Response, presence_user_nsaid: string) {
|
||||
if (!this.record_fest_votes?.read) {
|
||||
throw new ResponseError(404, 'not_found', 'Not recording fest voting status history');
|
||||
}
|
||||
|
||||
// Attempt to fetch the user's current presence to make sure they are
|
||||
// still friends with the presence server user
|
||||
await this.handlePresenceRequest(req, null, presence_user_nsaid);
|
||||
|
||||
const TimestampSymbol = Symbol('Timestamp');
|
||||
const VoteKeySymbol = Symbol('VoteKey');
|
||||
|
||||
const response: {
|
||||
result: {
|
||||
id: string;
|
||||
fest_id: string;
|
||||
fest_team_id: string;
|
||||
fest_team: FestTeam_votingStatus;
|
||||
updated_at: string;
|
||||
[TimestampSymbol]: number;
|
||||
[VoteKeySymbol]: string;
|
||||
}[];
|
||||
} = {
|
||||
result: [],
|
||||
};
|
||||
|
||||
const latest = new Map<string, [timestamp: Date, data: FestTeam_votingStatus]>();
|
||||
const all = req.query['include-all'] === '1';
|
||||
|
||||
for await (const dirent of await fs.opendir(this.record_fest_votes.path)) {
|
||||
if (!dirent.isDirectory() || !dirent.name.startsWith('splatnet3-fest-votes-')) continue;
|
||||
|
||||
const id = dirent.name.substr(21);
|
||||
const fest_votes_dir = path.join(this.record_fest_votes.path, dirent.name);
|
||||
|
||||
for await (const dirent of await fs.opendir(fest_votes_dir)) {
|
||||
const match = dirent.name.match(/^(\d+)\.json$/);
|
||||
if (!dirent.isFile() || !match) continue;
|
||||
|
||||
const timestamp = new Date(parseInt(match[1]));
|
||||
const is_latest = (latest.get(id)?.[0].getTime() ?? 0) <= timestamp.getTime();
|
||||
|
||||
if (!all && !is_latest) continue;
|
||||
|
||||
try {
|
||||
const data: FestVotingStatusRecord =
|
||||
JSON.parse(await fs.readFile(path.join(fest_votes_dir, dirent.name), 'utf-8'));
|
||||
|
||||
const friend = data.friends.result.nodes.find(f => Buffer.from(f.id, 'base64').toString()
|
||||
.match(/^Friend-([0-9a-f]{16})$/)?.[1] === presence_user_nsaid);
|
||||
if (!friend) continue;
|
||||
|
||||
const fest_team = this.getFestTeamVotingStatus(data.result, data.fest, friend);
|
||||
if (!fest_team) continue;
|
||||
|
||||
const fest_id = data.fest ?
|
||||
Buffer.from(data.fest.id, 'base64').toString()
|
||||
.match(/^Fest-([A-Z]{2}):(([A-Z]+)-(\d+))$/)?.[2] || data.fest.id :
|
||||
null;
|
||||
if (!fest_id) continue;
|
||||
|
||||
const fest_team_id =
|
||||
Buffer.from(fest_team.id, 'base64').toString()
|
||||
.match(/^FestTeam-([A-Z]{2}):((([A-Z]+)-(\d+)):([A-Za-z]+))$/)?.[2] || fest_team.id;
|
||||
|
||||
if (is_latest) latest.set(id, [timestamp, fest_team]);
|
||||
|
||||
if (!all) {
|
||||
let index;
|
||||
while ((index = response.result.findIndex(r => r.id === id)) >= 0) {
|
||||
response.result.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
response.result.push({
|
||||
id,
|
||||
fest_id,
|
||||
fest_team_id,
|
||||
fest_team,
|
||||
updated_at: timestamp.toISOString(),
|
||||
[TimestampSymbol]: timestamp.getTime(),
|
||||
[VoteKeySymbol]: fest_id + '/' + fest_team_id + '/' + fest_team.myVoteState,
|
||||
});
|
||||
} catch (err) {
|
||||
debug('Error reading fest voting status records', id, match[1], err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.result.length) throw new ResponseError(404, 'not_found', 'No fest voting status history for this user');
|
||||
|
||||
response.result.sort((a, b) => a[TimestampSymbol] - b[TimestampSymbol]);
|
||||
|
||||
response.result = response.result.filter((result, index, results) => {
|
||||
const prev_result = results[index - 1];
|
||||
return !prev_result || result[VoteKeySymbol] !== prev_result[VoteKeySymbol];
|
||||
});
|
||||
|
||||
response.result.reverse();
|
||||
|
||||
const images = await this.downloadImages(response.result, this.getResourceBaseUrls(req));
|
||||
|
||||
return {...response, [ResourceUrlMapSymbol]: images};
|
||||
}
|
||||
|
||||
presence_streams = new Set<EventStreamResponse>();
|
||||
|
||||
async handlePresenceStreamRequest(req: Request, res: Response, presence_user_nsaid: string) {
|
||||
|
|
@ -704,7 +1064,8 @@ class Server extends HttpServer {
|
|||
for (const [key, value] of Object.entries(result) as
|
||||
[keyof typeof result, typeof result[keyof typeof result]][]
|
||||
) {
|
||||
stream.sendEvent(key, value);
|
||||
if (typeof key !== 'string') continue;
|
||||
stream.sendEvent(key, {...value, [ResourceUrlMapSymbol]: result[ResourceUrlMapSymbol]});
|
||||
}
|
||||
|
||||
await new Promise(rs => setTimeout(rs, this.update_interval));
|
||||
|
|
@ -721,9 +1082,9 @@ class Server extends HttpServer {
|
|||
for (const [key, value] of Object.entries(result) as
|
||||
[keyof typeof result, typeof result[keyof typeof result]][]
|
||||
) {
|
||||
if (typeof key !== 'string') continue;
|
||||
if (JSON.stringify(value) === JSON.stringify(last_result[key])) continue;
|
||||
|
||||
stream.sendEvent(key, value);
|
||||
stream.sendEvent(key, {...value, [ResourceUrlMapSymbol]: result[ResourceUrlMapSymbol]});
|
||||
}
|
||||
|
||||
last_result = result;
|
||||
|
|
@ -735,8 +1096,9 @@ class Server extends HttpServer {
|
|||
|
||||
if (retry_after && /^\d+$/.test(retry_after)) {
|
||||
stream.sendEvent(null, 'debug: timestamp ' + new Date().toISOString(), {
|
||||
error: err,
|
||||
error: 'unknown_error',
|
||||
error_message: (err as Error).message,
|
||||
...err,
|
||||
});
|
||||
|
||||
await new Promise(rs => setTimeout(rs, parseInt(retry_after) * 1000));
|
||||
|
|
@ -745,17 +1107,7 @@ class Server extends HttpServer {
|
|||
}
|
||||
}
|
||||
|
||||
if (err instanceof ResponseError) {
|
||||
stream.sendEvent('error', {
|
||||
error: err.code,
|
||||
error_message: err.message,
|
||||
});
|
||||
} else {
|
||||
stream.sendEvent('error', {
|
||||
error: err,
|
||||
error_message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
stream.sendErrorEvent(err);
|
||||
|
||||
debug('Error in event stream %d', stream.id, err);
|
||||
|
||||
|
|
@ -772,8 +1124,7 @@ class Server extends HttpServer {
|
|||
req.headers.authorization.substr(3) : null;
|
||||
if (!token) throw new ResponseError(401, 'unauthorised');
|
||||
|
||||
const user = await this.splatnet3_users!.get(token);
|
||||
user.update_interval = this.update_interval;
|
||||
const user = await this.getSplatNet3UserBySessionToken(token);
|
||||
|
||||
await user.getFriends();
|
||||
return {result: user.friends};
|
||||
|
|
@ -786,13 +1137,25 @@ class Server extends HttpServer {
|
|||
req.headers.authorization.substr(3) : null;
|
||||
if (!token) throw new ResponseError(401, 'unauthorised');
|
||||
|
||||
const user = await this.splatnet3_users!.get(token);
|
||||
user.update_interval = this.update_interval;
|
||||
const user = await this.getSplatNet3UserBySessionToken(token);
|
||||
|
||||
await user.getSchedules();
|
||||
return {result: user.schedules!};
|
||||
}
|
||||
|
||||
async handleSplatNet3ProxyCurrentFest(req: Request, res: Response) {
|
||||
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
|
||||
|
||||
const token = req.headers.authorization?.substr(0, 3) === 'na ' ?
|
||||
req.headers.authorization.substr(3) : null;
|
||||
if (!token) throw new ResponseError(401, 'unauthorised');
|
||||
|
||||
const user = await this.getSplatNet3UserBySessionToken(token);
|
||||
|
||||
await user.getCurrentFest();
|
||||
return {result: user.current_fest};
|
||||
}
|
||||
|
||||
async handleSplatNet3ProxyCurrentFestVotingStatus(req: Request, res: Response) {
|
||||
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
|
||||
|
||||
|
|
@ -800,12 +1163,106 @@ class Server extends HttpServer {
|
|||
req.headers.authorization.substr(3) : null;
|
||||
if (!token) throw new ResponseError(401, 'unauthorised');
|
||||
|
||||
const user = await this.splatnet3_users!.get(token);
|
||||
user.update_interval = this.update_interval;
|
||||
const user = await this.getSplatNet3UserBySessionToken(token);
|
||||
|
||||
await user.getCurrentFestVotes();
|
||||
return {result: user.fest_vote_status};
|
||||
}
|
||||
|
||||
async downloadImages(data: unknown, base_url: {
|
||||
baas: string | null;
|
||||
atum: string | null;
|
||||
splatnet3: string | null;
|
||||
}): Promise<Record<string, string>> {
|
||||
const image_urls: [url: string, dir: string, base_url: string][] = [];
|
||||
|
||||
// Use JSON.stringify to iterate over everything in the response
|
||||
JSON.stringify(data, (key: string, value: unknown) => {
|
||||
if (this.image_proxy_path_baas && base_url.baas) {
|
||||
if (typeof value === 'string' &&
|
||||
value.startsWith('https://cdn-image-e0d67c509fb203858ebcb2fe3f88c2aa.baas.nintendo.com/')
|
||||
) {
|
||||
image_urls.push([value, this.image_proxy_path_baas, base_url.baas]);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.image_proxy_path_atum && base_url.atum) {
|
||||
if (typeof value === 'string' &&
|
||||
value.startsWith('https://atum-img-lp1.cdn.nintendo.net/')
|
||||
) {
|
||||
image_urls.push([value, this.image_proxy_path_atum, base_url.atum]);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.image_proxy_path_splatnet3 && base_url.splatnet3) {
|
||||
if (typeof value === 'object' && value && 'url' in value && typeof value.url === 'string') {
|
||||
if (value.url.toLowerCase().startsWith('https://api.lp1.av5ja.srv.nintendo.net/')) {
|
||||
image_urls.push([value.url, this.image_proxy_path_splatnet3, base_url.splatnet3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
|
||||
const url_map: Record<string, string> = {};
|
||||
|
||||
await Promise.all(image_urls.map(async ([url, dir, base_url]) => {
|
||||
url_map[url] = new URL(await this.downloadImage(url, dir), base_url).toString();
|
||||
}));
|
||||
|
||||
return url_map;
|
||||
}
|
||||
|
||||
getResourceBaseUrls(req: Request) {
|
||||
const base_url = process.env.BASE_URL ??
|
||||
(req.headers['x-forwarded-proto'] === 'https' ? 'https://' : 'http://') +
|
||||
req.headers.host;
|
||||
|
||||
return {
|
||||
baas: this.image_proxy_path_baas ? base_url + '/api/presence/resources/baas/' : null,
|
||||
atum: this.image_proxy_path_atum ? base_url + '/api/presence/resources/atum/' : null,
|
||||
splatnet3: this.image_proxy_path_splatnet3 ? base_url + '/api/splatnet3/resources/' : null,
|
||||
};
|
||||
}
|
||||
|
||||
downloadImage(url: string, dir: string) {
|
||||
const pathname = new URL(url).pathname;
|
||||
const name = pathname.substr(1).toLowerCase()
|
||||
.replace(/^resources\//g, '')
|
||||
.replace(/(\/|^)\.\.(\/|$)/g, '$1...$2') +
|
||||
(path.extname(pathname) ? '' : '.jpeg');
|
||||
|
||||
const promise = this.promise_image.get(dir + '/' + name) ?? Promise.resolve().then(async () => {
|
||||
try {
|
||||
await fs.stat(path.join(dir, name));
|
||||
return name;
|
||||
} catch (err) {}
|
||||
|
||||
debug('Fetching image %s', name);
|
||||
const response = await fetch(url);
|
||||
const data = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
if (!response.ok) throw new ErrorResponse('Unable to download resource ' + name, response, data.toString());
|
||||
|
||||
await mkdirp(path.dirname(path.join(dir, name)));
|
||||
await fs.writeFile(path.join(dir, name), data);
|
||||
|
||||
debug('Downloaded image %s', name);
|
||||
|
||||
return name;
|
||||
}).then(result => {
|
||||
this.promise_image.delete(dir + '/' + name);
|
||||
return result;
|
||||
}).catch(err => {
|
||||
this.promise_image.delete(dir + '/' + name);
|
||||
throw err;
|
||||
});
|
||||
|
||||
this.promise_image.set(dir + '/' + name, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
function createScheduleFest(
|
||||
|
|
@ -834,7 +1291,7 @@ function createFestVoteTeam(
|
|||
id: team.id,
|
||||
teamName: team.teamName,
|
||||
image: {
|
||||
url: getSplatoon3inkUrl(team.image.url),
|
||||
url: team.image.url,
|
||||
},
|
||||
color: team.color,
|
||||
votes: {nodes: []},
|
||||
|
|
@ -842,11 +1299,19 @@ function createFestVoteTeam(
|
|||
};
|
||||
}
|
||||
|
||||
function replacer(key: string, value: any) {
|
||||
if ((key === 'image' || key.endsWith('Image')) && value && typeof value === 'object' && 'url' in value) {
|
||||
function replacer(key: string, value: any, data: unknown) {
|
||||
const url_map = data && typeof data === 'object' && ResourceUrlMapSymbol in data &&
|
||||
data[ResourceUrlMapSymbol] && typeof data[ResourceUrlMapSymbol] === 'object' ?
|
||||
data[ResourceUrlMapSymbol] as Partial<Record<string, string>> : null;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return url_map?.[value] ?? value;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value && 'url' in value && typeof value.url === 'string') {
|
||||
return {
|
||||
...value,
|
||||
url: getSplatoon3inkUrl(value.url),
|
||||
url: url_map?.[value.url] ?? value.url,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,10 +71,7 @@ export class HttpServer {
|
|||
if (err instanceof ResponseError) {
|
||||
err.sendResponse(req, res);
|
||||
} else {
|
||||
this.sendJsonResponse(res, {
|
||||
error: err,
|
||||
error_message: (err as Error).message,
|
||||
}, 500);
|
||||
this.sendJsonResponse(res, getErrorObject(err), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -85,20 +82,28 @@ export class ResponseError extends Error {
|
|||
}
|
||||
|
||||
sendResponse(req: Request, res: Response) {
|
||||
const data = {
|
||||
error: this.code,
|
||||
error_message: this.message,
|
||||
};
|
||||
const data = this.toJSON();
|
||||
|
||||
res.statusCode = this.status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(req.headers['accept']?.match(/\/html\b/i) ?
|
||||
JSON.stringify(data, null, 4) : JSON.stringify(data));
|
||||
}
|
||||
|
||||
sendEventStreamEvent(events: EventStreamResponse) {
|
||||
events.sendEvent('error', this.toJSON());
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.code,
|
||||
error_message: this.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class EventStreamResponse {
|
||||
json_replacer: ((key: string, value: unknown) => any) | null = null;
|
||||
json_replacer: ((key: string, value: unknown, data: unknown) => any) | null = null;
|
||||
|
||||
private static id = 0;
|
||||
readonly id = EventStreamResponse.id++;
|
||||
|
|
@ -123,7 +128,96 @@ export class EventStreamResponse {
|
|||
|
||||
sendEvent(event: string | null, ...data: unknown[]) {
|
||||
if (event) this.res.write('event: ' + event + '\n');
|
||||
for (const d of data) this.res.write('data: ' + JSON.stringify(d, this.json_replacer ?? undefined) + '\n');
|
||||
for (const d of data) {
|
||||
if (d instanceof EventStreamField) d.write(this.res);
|
||||
else this.res.write('data: ' + JSON.stringify(d,
|
||||
this.json_replacer ? (k, v) => this.json_replacer?.call(null, k, v, d) : undefined) + '\n');
|
||||
}
|
||||
this.res.write('\n');
|
||||
}
|
||||
|
||||
sendErrorEvent(err: unknown) {
|
||||
if (err instanceof ResponseError) {
|
||||
err.sendEventStreamEvent(this);
|
||||
} else {
|
||||
this.sendEvent('error', getErrorObject(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class EventStreamField {
|
||||
abstract write(res: Response): void;
|
||||
}
|
||||
|
||||
export class EventStreamLastEventId extends EventStreamField {
|
||||
constructor(
|
||||
readonly id: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
if (!/^[0-9a-z-_\.:;]+$/i.test(id)) {
|
||||
throw new TypeError('Invalid event ID');
|
||||
}
|
||||
}
|
||||
|
||||
write(res: Response) {
|
||||
res.write('id: ' + this.id + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
export class EventStreamRetryTime extends EventStreamField {
|
||||
constructor(
|
||||
readonly retry_ms: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
write(res: Response) {
|
||||
res.write('retry: ' + this.retry_ms + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
export class EventStreamRawData extends EventStreamField {
|
||||
constructor(
|
||||
readonly data: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
if (/[\0\n\r]/.test(data)) {
|
||||
throw new TypeError('Invalid data');
|
||||
}
|
||||
}
|
||||
|
||||
write(res: Response) {
|
||||
res.write('data: ' + this.data + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorObject(err: unknown) {
|
||||
if (err instanceof ResponseError) {
|
||||
return err.toJSON();
|
||||
}
|
||||
|
||||
if (err && typeof err === 'object' && 'type' in err && 'code' in err && 'message' in err && err.type === 'system') {
|
||||
return {
|
||||
error: 'unknown_error',
|
||||
error_message: err.message,
|
||||
error_code: err.code,
|
||||
...err,
|
||||
};
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
error: 'unknown_error',
|
||||
error_message: err.message,
|
||||
...err,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'unknown_error',
|
||||
error_message: (err as Error)?.message,
|
||||
...(err as object),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,8 +79,9 @@ class ZncDiscordPresenceClient {
|
|||
|
||||
if (this.m.presence_enabled && this.m.discord_preconnect) {
|
||||
if (this.rpc) {
|
||||
debugDiscord('No presence but Discord preconnect enabled - clearing Discord activity');
|
||||
if (this.title) debugDiscord('No presence but Discord preconnect enabled - clearing Discord activity');
|
||||
this.setActivity(this.rpc.id);
|
||||
this.title = null;
|
||||
} else {
|
||||
debugDiscord('No presence but Discord preconnect enabled - connecting');
|
||||
const discordpresence = getInactiveDiscordPresence(PresenceState.OFFLINE, 0);
|
||||
|
|
@ -235,7 +236,8 @@ class ZncDiscordPresenceClient {
|
|||
if (!this.rpc) {
|
||||
this.connect(client_id, this.m.discord_client_filter);
|
||||
} else {
|
||||
this.rpc.client.setActivity(typeof activity === 'string' ? undefined : activity.activity);
|
||||
if (typeof activity === 'string') this.rpc.client.clearActivity();
|
||||
else this.rpc.client.setActivity(activity.activity);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -570,14 +570,14 @@ export const titles: Title[] = [
|
|||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
// {
|
||||
// // Pokémon Mystery Dungeon: Rescue Team DX Demo
|
||||
// id: '', // TODO
|
||||
// client: '966387876520685668',
|
||||
// largeImageText: 'Demo',
|
||||
// showPlayingOnline: true,
|
||||
// showActiveEvent: true,
|
||||
// },
|
||||
{
|
||||
// Pokémon Mystery Dungeon: Rescue Team DX Demo
|
||||
id: '010040800fb54000',
|
||||
client: '966387876520685668',
|
||||
largeImageText: 'Demo',
|
||||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
|
||||
{
|
||||
// Super Mario Party
|
||||
|
|
@ -666,14 +666,14 @@ export const titles: Title[] = [
|
|||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
// {
|
||||
// // Cadence of Hyrule - Crypt of the NecroDancer Featuring The Legend of Zelda Demo
|
||||
// id: '', // TODO
|
||||
// client: '966441763973763162',
|
||||
// largeImageText: 'Demo',
|
||||
// showPlayingOnline: true,
|
||||
// showActiveEvent: true,
|
||||
// },
|
||||
{
|
||||
// Cadence of Hyrule - Crypt of the NecroDancer Featuring The Legend of Zelda Demo
|
||||
id: '010065700ee06000',
|
||||
client: '966441763973763162',
|
||||
largeImageText: 'Demo',
|
||||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
|
||||
{
|
||||
// Dragon Quest XI S: Echoes of an Elusive Age - Definitive Edition
|
||||
|
|
@ -682,14 +682,14 @@ export const titles: Title[] = [
|
|||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
// {
|
||||
// // Dragon Quest XI S: Echoes of an Elusive Age - Definitive Edition Demo
|
||||
// id: '', // TODO
|
||||
// client: '966441876267864084',
|
||||
// largeImageText: 'Demo',
|
||||
// showPlayingOnline: true,
|
||||
// showActiveEvent: true,
|
||||
// },
|
||||
{
|
||||
// Dragon Quest XI S: Echoes of an Elusive Age - Definitive Edition [Demo version]
|
||||
id: '010026800ea0a000',
|
||||
client: '966441876267864084',
|
||||
largeImageText: 'Demo',
|
||||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
|
||||
{
|
||||
// Super Kirby Clash
|
||||
|
|
@ -826,14 +826,14 @@ export const titles: Title[] = [
|
|||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
// {
|
||||
// // Hyrule Warriors: Age of Calamity Demo
|
||||
// id: '', // TODO
|
||||
// client: '966479236762325013',
|
||||
// largeImageText: 'Demo',
|
||||
// showPlayingOnline: true,
|
||||
// showActiveEvent: true,
|
||||
// },
|
||||
{
|
||||
// Hyrule Warriors: Age of Calamity - Demo Version
|
||||
id: '0100a2c01320e000',
|
||||
client: '966479236762325013',
|
||||
largeImageText: 'Demo',
|
||||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
|
||||
{
|
||||
// Fitness Boxing 2: Rhythm & Exercise
|
||||
|
|
@ -1176,4 +1176,46 @@ export const titles: Title[] = [
|
|||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
|
||||
{
|
||||
// Bayonetta Origins: Cereza and the Lost Demon
|
||||
id: '0100cf5010fec000',
|
||||
client: '1107032391496761375',
|
||||
titleName: 'Bayonetta Origins: Cereza and the Lost Demon',
|
||||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
{
|
||||
// Bayonetta Origins: Cereza and the Lost Demon Demo
|
||||
id: '010002801a3fa000',
|
||||
client: '1107032391496761375',
|
||||
largeImageText: 'Bayonetta Origins: Cereza and the Lost Demon Demo',
|
||||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
|
||||
{
|
||||
// The Legend of Zelda: Tears of the Kingdom
|
||||
id: '0100f2c0115b6000',
|
||||
client: '1107033191912579183',
|
||||
largeImageText: 'The Legend of Zelda: Tears of the Kingdom',
|
||||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
|
||||
// {
|
||||
// // Pikmin 4
|
||||
// id: '',
|
||||
// client: '1107033455755264010',
|
||||
// showPlayingOnline: true,
|
||||
// showActiveEvent: true,
|
||||
// },
|
||||
|
||||
{
|
||||
// Metroid Prime Remastered
|
||||
id: '010012101468c000',
|
||||
client: '1107120929953284150',
|
||||
showPlayingOnline: true,
|
||||
showActiveEvent: true,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export { getTitleIdFromEcUrl } from '../util/misc.js';
|
||||
export { ErrorResponse, ResponseSymbol } from '../api/util.js';
|
||||
export { addUserAgent } from '../util/useragent.js';
|
||||
export { addUserAgent, addUserAgentFromPackageJson } from '../util/useragent.js';
|
||||
|
||||
export { version } from '../util/product.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const temporary_system_errors = {
|
|||
'ENOTFOUND': null,
|
||||
'EAI_AGAIN': 'name resolution failed',
|
||||
'ECONNRESET': 'connection reset',
|
||||
'ENETUNREACH': 'network unreachable',
|
||||
};
|
||||
export const temporary_http_errors = [
|
||||
502, // Bad Gateway
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as process from 'node:process';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { docker, git, release, version } from '../util/product.js';
|
||||
|
||||
const default_useragent = 'nxapi/' + version + ' (' +
|
||||
|
|
@ -19,6 +20,68 @@ export function addUserAgent(...useragent: string[]) {
|
|||
additional_useragents.push(...useragent);
|
||||
}
|
||||
|
||||
export function addUserAgentFromPackageJson(pkg: string | URL, additional?: string): Promise<void>;
|
||||
export function addUserAgentFromPackageJson(pkg: object, additional?: string): void;
|
||||
export function addUserAgentFromPackageJson(pkg: string | URL | object, additional?: string) {
|
||||
if (typeof pkg === 'string' || pkg instanceof URL) {
|
||||
return fs.readFile(pkg, 'utf-8').then(pkg => addUserAgentFromPackageJson(JSON.parse(pkg)));
|
||||
}
|
||||
|
||||
const name = 'name' in pkg && typeof pkg.name === 'string' ? pkg.name : null;
|
||||
const version = 'version' in pkg && typeof pkg.version === 'string' ? pkg.version : null;
|
||||
if (!name || !version) throw new Error('package.json does not contain valid name and version fields');
|
||||
|
||||
const homepage = 'homepage' in pkg ? pkg.homepage : null;
|
||||
if (homepage != null && typeof homepage !== 'string') throw new Error('package.json contains an invalid homepage field');
|
||||
const repository = 'repository' in pkg && pkg.repository != null ?
|
||||
getPackageJsonRepository(pkg.repository) : null;
|
||||
|
||||
const end =
|
||||
(homepage ? '+' + homepage : repository ? '+' + repository.url : '') +
|
||||
(repository && additional ? '; ' : '') +
|
||||
additional;
|
||||
|
||||
const useragent = name + '/' + version + (end ? ' (' + end + ')' : '');
|
||||
|
||||
addUserAgent(useragent);
|
||||
}
|
||||
|
||||
function getPackageJsonRepository(repository: unknown): {
|
||||
type: string; url: string; directory?: string | null;
|
||||
} {
|
||||
if (typeof repository === 'string') {
|
||||
if (repository.startsWith('github:')) {
|
||||
return {type: 'git', url: 'https://github.com/' + repository.substr(7)};
|
||||
}
|
||||
if (repository.startsWith('gist:')) {
|
||||
return {type: 'git', url: 'https://gist.github.com/' + repository.substr(5)};
|
||||
}
|
||||
if (repository.startsWith('bitbucket:')) {
|
||||
return {type: 'git', url: 'https://bitbucket.org/' + repository.substr(10)};
|
||||
}
|
||||
if (repository.startsWith('gitlab:')) {
|
||||
return {type: 'git', url: 'https://gitlab.com/' + repository.substr(7)};
|
||||
}
|
||||
if (repository.match(/^[0-9a-z-.]+\/[0-9a-z-.]+$/i)) {
|
||||
return {type: 'git', url: 'https://github.com/' + repository};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof repository === 'object' && repository) {
|
||||
if ('type' in repository && typeof repository.type === 'string' &&
|
||||
'url' in repository && typeof repository.url === 'string' &&
|
||||
(!('directory' in repository) || repository.directory == null || typeof repository.directory === 'string')
|
||||
) {
|
||||
return {
|
||||
type: repository.type, url: repository.url,
|
||||
directory: 'directory' in repository ? repository.directory as string : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('package.json contains an invalid repository field');
|
||||
}
|
||||
|
||||
/**
|
||||
* Only used by cli/nso/http-server.ts.
|
||||
* This command is intended to be run automatically and doesn't make any requests itself, so this function removes
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user