mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-03-21 18:04:10 -05:00
254 lines
9.2 KiB
TypeScript
254 lines
9.2 KiB
TypeScript
import * as crypto from 'node:crypto';
|
|
import * as persist from 'node-persist';
|
|
import { Response } from 'undici';
|
|
import createDebug from '../util/debug.js';
|
|
import CoralApi, { CoralApiInterface, Result } from '../api/coral.js';
|
|
import ZncProxyApi from '../api/znc-proxy.js';
|
|
import { Announcements, Friends, Friend, GetActiveEventResult, CoralSuccessResponse, WebService, WebServices, CoralError } from '../api/coral-types.js';
|
|
import { getToken, SavedToken } from './auth/coral.js';
|
|
import type { Store } from '../app/main/index.js';
|
|
import { NintendoAccountUser } from '../api/na.js';
|
|
|
|
const debug = createDebug('nxapi:users');
|
|
|
|
export interface UserData {
|
|
created_at: number;
|
|
expires_at: number;
|
|
}
|
|
|
|
export default class Users<T extends UserData> {
|
|
private users = new Map<string, T>();
|
|
private promise = new Map<string, Promise<T>>();
|
|
private _get: (token: string) => Promise<T>;
|
|
|
|
constructor(get: (token: string) => Promise<T>) {
|
|
this._get = get;
|
|
}
|
|
|
|
async get(token: string): Promise<T> {
|
|
const existing = this.users.get(token);
|
|
|
|
if (existing && existing.expires_at >= Date.now()) {
|
|
return existing;
|
|
}
|
|
|
|
const promise = this.promise.get(token) ?? this._get.call(null, token).then(data => {
|
|
this.users.set(token, data);
|
|
return data;
|
|
}).finally(() => {
|
|
this.promise.delete(token);
|
|
});
|
|
|
|
this.promise.set(token, promise);
|
|
|
|
return promise;
|
|
}
|
|
|
|
async remove(token: string) {
|
|
const promise = this.promise.get(token);
|
|
this.promise.delete(token);
|
|
|
|
await promise;
|
|
this.users.delete(token);
|
|
}
|
|
|
|
static coral(store: Store | persist.LocalStorage, znc_proxy_url: string, ratelimit?: boolean): Users<CoralUser<ZncProxyApi>>
|
|
static coral(store: Store | persist.LocalStorage, znc_proxy_url?: undefined, ratelimit?: boolean): Users<CoralUser<CoralApi>>
|
|
static coral(store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users<CoralUser<CoralApiInterface>>
|
|
static coral(_store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean) {
|
|
const store = 'storage' in _store ? _store : null;
|
|
const storage = 'storage' in _store ? _store.storage : _store;
|
|
|
|
const cached_webservices = new Map</** language */ string, string>();
|
|
|
|
return new Users(async token => {
|
|
const {nso, data} = await getToken(storage, token, znc_proxy_url, ratelimit);
|
|
|
|
const [announcements, friends, webservices, active_event] = await Promise.all([
|
|
nso.getAnnouncements(),
|
|
nso.getFriendList(),
|
|
nso.getWebServices(),
|
|
nso.getActiveEvent(),
|
|
]);
|
|
|
|
const user = new CoralUser(nso, data, announcements, friends, webservices, active_event);
|
|
|
|
if (nso instanceof CoralApi && nso.onTokenExpired) {
|
|
const renewToken = nso.onTokenExpired;
|
|
|
|
nso.onTokenExpired = async (error?: CoralError, response?: Response) => {
|
|
const auth_data = await renewToken(error, response) as SavedToken;
|
|
user.data = auth_data;
|
|
return auth_data;
|
|
};
|
|
}
|
|
|
|
if (store) {
|
|
await maybeUpdateWebServicesListCache(cached_webservices, store, data.user, webservices);
|
|
user.onUpdatedWebServices = webservices => {
|
|
maybeUpdateWebServicesListCache(cached_webservices, store, data.user, webservices);
|
|
};
|
|
}
|
|
|
|
return user;
|
|
});
|
|
}
|
|
}
|
|
|
|
export interface CoralUserData<T extends CoralApiInterface = CoralApi> extends UserData {
|
|
nso: T;
|
|
data: SavedToken;
|
|
announcements: CoralSuccessResponse<Announcements>;
|
|
friends: CoralSuccessResponse<Friends>;
|
|
webservices: CoralSuccessResponse<WebServices>;
|
|
active_event: CoralSuccessResponse<GetActiveEventResult>;
|
|
}
|
|
|
|
export class CoralUser<T extends CoralApiInterface = CoralApi> implements CoralUserData<T> {
|
|
created_at = Date.now();
|
|
expires_at = Infinity;
|
|
|
|
promise = new Map<string, Promise<void>>();
|
|
delay_retry_after_error_until: number | null = null;
|
|
|
|
updated = {
|
|
announcements: Date.now(),
|
|
friends: Date.now(),
|
|
webservices: Date.now(),
|
|
active_event: Date.now(),
|
|
};
|
|
|
|
delay_retry_after_error = 5 * 1000; // 5 seconds
|
|
update_interval = 10 * 1000; // 10 seconds
|
|
update_interval_announcements = 30 * 60 * 1000; // 30 minutes
|
|
|
|
onUpdatedWebServices: ((webservices: Result<WebServices>) => void) | null = null;
|
|
|
|
constructor(
|
|
public nso: T,
|
|
public data: SavedToken,
|
|
public announcements: CoralSuccessResponse<Announcements>,
|
|
public friends: CoralSuccessResponse<Friends>,
|
|
public webservices: CoralSuccessResponse<WebServices>,
|
|
public active_event: CoralSuccessResponse<GetActiveEventResult>,
|
|
) {}
|
|
|
|
private async update(key: keyof CoralUser['updated'], callback: () => Promise<void>, ttl: number) {
|
|
if ((this.updated[key] + ttl) < Date.now()) {
|
|
const promise = this.promise.get(key) ?? Promise.resolve().then(() => {
|
|
const delay_retry = (this.delay_retry_after_error_until ?? 0) - Date.now();
|
|
|
|
return delay_retry > 0 ? new Promise(rs => setTimeout(rs, delay_retry)) : null;
|
|
}).then(() => callback.call(null)).then(() => {
|
|
this.updated[key] = Date.now();
|
|
this.delay_retry_after_error_until = null;
|
|
this.promise.delete(key);
|
|
}).catch(err => {
|
|
this.delay_retry_after_error_until = Date.now() + this.delay_retry_after_error;
|
|
this.promise.delete(key);
|
|
throw err;
|
|
});
|
|
|
|
this.promise.set(key, promise);
|
|
|
|
await promise;
|
|
} else {
|
|
debug('Not updating %s data for coral user %s', key, this.data.nsoAccount.user.name);
|
|
}
|
|
}
|
|
|
|
async getAnnouncements() {
|
|
await this.update('announcements', async () => {
|
|
this.announcements = await this.nso.getAnnouncements();
|
|
}, this.update_interval_announcements);
|
|
|
|
return this.announcements.result;
|
|
}
|
|
|
|
async getFriends() {
|
|
await this.update('friends', async () => {
|
|
this.friends = await this.nso.getFriendList();
|
|
}, this.update_interval);
|
|
|
|
return this.friends.result.friends;
|
|
}
|
|
|
|
async getWebServices() {
|
|
await this.update('webservices', async () => {
|
|
const webservices = this.webservices = await this.nso.getWebServices();
|
|
|
|
this.onUpdatedWebServices?.call(null, webservices);
|
|
}, this.update_interval);
|
|
|
|
return this.webservices.result;
|
|
}
|
|
|
|
async getActiveEvent() {
|
|
await this.update('active_event', async () => {
|
|
this.active_event = await this.nso.getActiveEvent();
|
|
}, this.update_interval);
|
|
|
|
return this.active_event.result;
|
|
}
|
|
|
|
async addFriend(nsa_id: string) {
|
|
if (!(this.nso instanceof CoralApi)) {
|
|
throw new Error('Cannot send friend requests using Coral API proxy');
|
|
}
|
|
|
|
if (nsa_id === this.data.nsoAccount.user.nsaId) {
|
|
throw new Error('Cannot add self as a friend');
|
|
}
|
|
|
|
const result = await this.nso.sendFriendRequest(nsa_id);
|
|
|
|
// Check if the user is now friends
|
|
// The Nintendo Switch Online app doesn't do this, but if the other user already sent a friend request to
|
|
// this user, they will be added as friends immediately. If the user is now friends we can show a message
|
|
// saying that, instead of saying that a friend request was sent when the user actually just accepted the
|
|
// other user's friend request.
|
|
let friend: Friend | null = null;
|
|
|
|
try {
|
|
// Clear the last updated timestamp to force updating the friend list
|
|
this.updated.friends = 0;
|
|
|
|
const friends = await this.getFriends();
|
|
friend = friends.find(f => f.nsaId === nsa_id) ?? null;
|
|
} catch (err) {
|
|
debug('Error updating friend list for %s to check if a friend request was accepted',
|
|
this.data.nsoAccount.user.name, err);
|
|
}
|
|
|
|
return {result, friend};
|
|
}
|
|
}
|
|
|
|
export interface CachedWebServicesList {
|
|
webservices: WebService[];
|
|
updated_at: number;
|
|
language: string;
|
|
user: string;
|
|
}
|
|
|
|
async function maybeUpdateWebServicesListCache(
|
|
cached_webservices: Map<string, string>, store: Store, // storage: persist.LocalStorage,
|
|
user: NintendoAccountUser, webservices: WebService[]
|
|
) {
|
|
const webservices_hash = crypto.createHash('sha256').update(JSON.stringify(webservices)).digest('hex');
|
|
if (cached_webservices.get(user.language) === webservices_hash) return;
|
|
|
|
debug('Updating web services list', user.language);
|
|
|
|
const cache: CachedWebServicesList = {
|
|
webservices,
|
|
updated_at: Date.now(),
|
|
language: user.language,
|
|
user: user.id,
|
|
};
|
|
|
|
await store.storage.setItem('CachedWebServicesList.' + user.language, cache);
|
|
cached_webservices.set(user.language, webservices_hash);
|
|
store?.emit('update-cached-web-services', user.language, cache);
|
|
}
|