From 44529471874be746b3a22cd10e06aad0b08a8e9d Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Fri, 25 Jul 2025 17:54:18 +0100 Subject: [PATCH] Presence monitoring --- src/api/coral.ts | 4 +- src/api/f.ts | 5 ++ src/api/znc-proxy.ts | 8 ++- src/app/main/index.ts | 8 +-- src/app/main/ipc.ts | 9 +-- src/app/main/menu.ts | 23 ++++---- src/app/main/monitor.ts | 38 +++++++----- src/cli/nso/http-server.ts | 100 ++++++++++++++++++------------- src/cli/nso/notify.ts | 9 ++- src/cli/nso/presence.ts | 9 ++- src/common/notify.ts | 117 ++++--------------------------------- src/common/presence.ts | 105 ++++++++++++--------------------- src/common/users.ts | 2 +- 13 files changed, 182 insertions(+), 255 deletions(-) diff --git a/src/api/coral.ts b/src/api/coral.ts index d9df262..4968dda 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -5,8 +5,8 @@ import { JwtPayload } from '../util/jwt.js'; import { timeoutSignal } from '../util/misc.js'; import { getAdditionalUserAgents } from '../util/useragent.js'; import type { CoralRemoteConfig } from '../common/remote-config.js'; -import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParameter, Announcements, Announcements_4, BlockingUsers, CoralError, CoralResponse, CoralStatus, CoralSuccessResponse, CurrentUser, CurrentUserPermissions, Event, Friend_4, FriendCodeUrl, FriendCodeUser, Friends, Friends_4, GetActiveEventResult, ListChat, ListHashtag, ListHashtagParameter, ListMedia, ListMediaParameter, ListPushNotificationSettings, Media, PlayLogPermissions, PresencePermissions, PushNotificationPlayInvitationScope, ReceivedFriendRequest, ReceivedFriendRequests, SentFriendRequests, ShowUserLogin, UpdatePushNotificationSettingsParameter, UpdatePushNotificationSettingsParameterItem, User, UserPlayLog, WebServices, WebServices_4, WebServiceToken, WebServiceTokenParameter } from './coral-types.js'; -import { createZncaApi, DecryptResponseResult, FResult, getDefaultZncaApi, getPreferredZncaApiFromEnvironment, HashMethod, RequestEncryptionProvider, ZncaApi, ZncaApiNxapi } from './f.js'; +import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParameter, Announcements_4, BlockingUsers, CoralError, CoralResponse, CoralStatus, CoralSuccessResponse, CurrentUser, CurrentUserPermissions, Event, Friend_4, FriendCodeUrl, FriendCodeUser, Friends_4, GetActiveEventResult, ListChat, ListHashtag, ListHashtagParameter, ListMedia, ListMediaParameter, ListPushNotificationSettings, Media, PlayLogPermissions, PresencePermissions, PushNotificationPlayInvitationScope, ReceivedFriendRequest, ReceivedFriendRequests, SentFriendRequests, ShowUserLogin, UpdatePushNotificationSettingsParameter, UpdatePushNotificationSettingsParameterItem, User, UserPlayLog, WebServices_4, WebServiceToken, WebServiceTokenParameter } from './coral-types.js'; +import { createZncaApi, DecryptResponseResult, FResult, HashMethod, RequestEncryptionProvider, ZncaApi } from './f.js'; import { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountScope, NintendoAccountSessionAuthorisation, NintendoAccountToken, NintendoAccountUser } from './na.js'; import { ErrorResponse, ResponseSymbol } from './util.js'; import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../util/errors.js'; diff --git a/src/api/f.ts b/src/api/f.ts index 647e454..90e5cc8 100644 --- a/src/api/f.ts +++ b/src/api/f.ts @@ -241,6 +241,11 @@ export interface AndroidZncaEncryptRequestRequest { export interface AndroidZncaDecryptResponseRequest { data: string; + request_nsa_assertion?: boolean; +} +export interface AndroidZncaDecryptResponseResponse { + data: string; + nsa_assertion?: string | null; } export interface AndroidZncaFError { diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 0b272f5..c63d436 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -1,5 +1,5 @@ import { fetch, Response } from 'undici'; -import { ActiveEvent, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl, WebService_4, Media, Announcements_4, Friend_4, PresenceOnline_4, PresenceOnline, PresenceOffline } from './coral-types.js'; +import { ActiveEvent, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl, WebService_4, Media, Announcements_4, Friend_4, PresenceOnline_4, PresenceOnline, PresenceOffline, GetActiveEventResult } from './coral-types.js'; import { defineResponse, ErrorResponse, ResponseSymbol } from './util.js'; import { AbstractCoralApi, CoralApiInterface, CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, RequestFlagAddPlatformSymbol, RequestFlagAddProductVersionSymbol, RequestFlagNoParameterSymbol, RequestFlagRequestIdSymbol, RequestFlags, ResponseDataSymbol, ResponseEncryptionSymbol, Result } from './coral.js'; import { NintendoAccountToken, NintendoAccountUser } from './na.js'; @@ -111,8 +111,8 @@ export default class ZncProxyApi extends AbstractCoralApi implements CoralApiInt } async getActiveEvent() { - const result = await this.fetchProxyApi<{activeevent: ActiveEvent}>('activeevent'); - return createResult(result, result.activeevent); + const result = await this.fetchProxyApi<{activeevent: ActiveEvent | null}>('activeevent'); + return createResult(result, result.activeevent ?? {}); } async getEvent(id: number) { @@ -235,6 +235,8 @@ export interface AuthPolicy { friend_presence?: boolean; webservices?: boolean; activeevent?: boolean; + chats?: boolean; + media?: boolean; current_user?: boolean; current_user_presence?: boolean; diff --git a/src/app/main/index.ts b/src/app/main/index.ts index a363ba8..5ec1834 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -480,11 +480,11 @@ export class Store extends EventEmitter { }; for (const monitor of monitors.monitors) { - if (monitor instanceof EmbeddedPresenceMonitor && !users.has(monitor.data.user.id)) { - users.add(monitor.data?.user.id); + if (monitor instanceof EmbeddedPresenceMonitor && !users.has(monitor.user.data.user.id)) { + users.add(monitor.user.data.user.id); state.users.push({ - id: monitor.data?.user.id, + id: monitor.user.data.user.id, user_notifications: monitor.user_notifications, friend_notifications: monitor.friend_notifications, }); @@ -541,7 +541,7 @@ export class Store extends EventEmitter { await monitors.start(user.id, monitor => { monitor.presence_user = state.discord_presence && 'na_id' in state.discord_presence.source && state.discord_presence.source.na_id === user.id ? - state.discord_presence.source.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId : null; + state.discord_presence.source.friend_nsa_id ?? monitor.user.data.nsoAccount.user.nsaId : null; monitor.user_notifications = user.user_notifications; monitor.friend_notifications = user.friend_notifications; diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts index 0d5c6e1..8342966 100644 --- a/src/app/main/ipc.ts +++ b/src/app/main/ipc.ts @@ -195,7 +195,8 @@ export function sendToAllWindows(channel: string, ...args: any[]) { function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean, window?: BrowserWindow) { const t = app.i18n.getFixedT(null, 'menus', 'user'); const dm = app.monitors.getActiveDiscordPresenceMonitor(); - const monitor = app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && m.data.user.id === user.id); + const monitor = app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && + m.user.data.user.id === user.id); return Menu.buildFromTemplate([ new MenuItem({label: t('na_id', {id: user.id})!, enabled: false}), @@ -215,9 +216,9 @@ function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, m click: () => app.menu?.setActiveDiscordPresenceUser(null)}), ] : dm?.presence_user === nso.nsaId ? [ new MenuItem({label: t('discord_enabled_via', {name: - dm.data.user.nickname + - (dm.data.user.nickname !== dm.data.nsoAccount.user.name ? - '/' + dm.data.nsoAccount.user.name : '')})!, + dm.user.data.user.nickname + + (dm.user.data.user.nickname !== dm.user.data.nsoAccount.user.name ? + '/' + dm.user.data.nsoAccount.user.name : '')})!, enabled: false}), new MenuItem({label: t('discord_disable')!, click: () => app.menu?.setActiveDiscordPresenceUser(null)}), diff --git a/src/app/main/menu.ts b/src/app/main/menu.ts index 794e41a..2cbc63d 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -61,9 +61,10 @@ export default class MenuApp { if (!data) continue; const monitor = this.app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && - m.data.user.id === data.user.id); - const discord_presence_active = discord_presence_monitor instanceof EmbeddedPresenceMonitor && - discord_presence_monitor?.data?.user.id === data.user.id; + m.user.data.user.id === data.user.id); + const discord_presence_active = discord_presence_monitor && + discord_presence_monitor instanceof EmbeddedPresenceMonitor && + discord_presence_monitor.user.data.user.id === data.user.id; const webservices = await this.getWebServiceItems(data.user.language, token); @@ -204,7 +205,7 @@ export default class MenuApp { const monitor = this.getActiveDiscordPresenceMonitor(); if (monitor) { - if (monitor instanceof EmbeddedPresenceMonitor && monitor.data.user.id === id) return; + if (monitor instanceof EmbeddedPresenceMonitor && monitor.user.data.user.id === id) return; monitor.discord.updatePresenceForDiscord(null); @@ -212,7 +213,7 @@ export default class MenuApp { monitor.presence_user = null; if (!monitor.user_notifications && !monitor.friend_notifications) { - this.app.monitors.stop(monitor.data.user.id); + this.app.monitors.stop(monitor.user.data.user.id); } } @@ -222,7 +223,7 @@ export default class MenuApp { } if (id) await this.app.monitors.start(id, monitor => { - monitor.presence_user = monitor.data.nsoAccount.user.nsaId; + monitor.presence_user = monitor.user.data.nsoAccount.user.nsaId; monitor.skipIntervalInCurrentLoop(); }); @@ -230,13 +231,14 @@ export default class MenuApp { } async setUserNotificationsActive(id: string, active: boolean) { - const monitor = this.app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && m.data.user.id === id); + const monitor = this.app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && + m.user.data.user.id === id); if (monitor?.user_notifications && !active) { monitor.user_notifications = false; if (!monitor.presence_user && !monitor.friend_notifications) { - this.app.monitors.stop(monitor.data.user.id); + this.app.monitors.stop(monitor.user.data.user.id); } monitor.skipIntervalInCurrentLoop(); @@ -251,13 +253,14 @@ export default class MenuApp { } async setFriendNotificationsActive(id: string, active: boolean) { - const monitor = this.app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && m.data.user.id === id); + const monitor = this.app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && + m.user.data.user.id === id); if (monitor?.friend_notifications && !active) { monitor.friend_notifications = false; if (!monitor.presence_user && !monitor.user_notifications) { - this.app.monitors.stop(monitor.data.user.id); + this.app.monitors.stop(monitor.user.data.user.id); } monitor.skipIntervalInCurrentLoop(); diff --git a/src/app/main/monitor.ts b/src/app/main/monitor.ts index 892e392..005ea3d 100644 --- a/src/app/main/monitor.ts +++ b/src/app/main/monitor.ts @@ -1,5 +1,6 @@ import { Notification } from 'electron'; import { i18n } from 'i18next'; +import { LocalStorage } from 'node-persist'; import { App } from './index.js'; import { showErrorDialog, tryGetNativeImageFromUrl } from './util.js'; import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource, DiscordStatus } from '../common/types.js'; @@ -13,9 +14,10 @@ import { DiscordPresence, DiscordPresencePlayTime, ErrorResult } from '../../dis import { DiscordRpcClient } from '../../discord/rpc.js'; import SplatNet3Monitor, { getConfigFromAppConfig as getSplatNet3MonitorConfigFromAppConfig } from '../../discord/monitor/splatoon3.js'; import { ErrorDescription } from '../../util/errors.js'; -import { CoralErrorResponse } from '../../api/coral.js'; +import { CoralApiInterface, CoralErrorResponse } from '../../api/coral.js'; import { NintendoAccountAuthErrorResponse, NintendoAccountErrorResponse } from '../../api/na.js'; import { InvalidNintendoAccountTokenError } from '../../common/auth/na.js'; +import { CoralUser } from '../../common/users.js'; const debug = createDebug('app:main:monitor'); @@ -37,13 +39,15 @@ export class PresenceMonitorManager { const user = await this.app.store.users.get(token); - const existing = this.monitors.find(m => m instanceof EmbeddedPresenceMonitor && m.data.user.id === user.data.user.id); + const existing = this.monitors.find(m => m instanceof EmbeddedPresenceMonitor && + m.user.data.user.id === user.data.user.id); + if (existing) { await callback?.call(null, existing as EmbeddedPresenceMonitor, false); return existing; } - const i = new EmbeddedPresenceMonitor(this.app.store.storage, token, user.nso, user.data, user); + const i = new EmbeddedPresenceMonitor(user, this.app.store.storage, token); i.notifications = this.notifications; i.presence_user = null; @@ -143,7 +147,7 @@ export class PresenceMonitorManager { async stop(id: string) { let index; while ((index = this.monitors.findIndex(m => - (m instanceof EmbeddedPresenceMonitor && m.data.user.id === id) || + (m instanceof EmbeddedPresenceMonitor && m.user.data.user.id === id) || (m instanceof EmbeddedProxyPresenceMonitor && m.presence_url === id) )) >= 0) { const i = this.monitors[index]; @@ -283,8 +287,8 @@ export class PresenceMonitorManager { return monitor instanceof EmbeddedProxyPresenceMonitor ? { url: monitor.presence_url, } : { - na_id: monitor.data.user.id, - friend_nsa_id: monitor.presence_user === monitor.data.nsoAccount.user.nsaId ? undefined : + na_id: monitor.user.data.user.id, + friend_nsa_id: monitor.presence_user === monitor.user.data.nsoAccount.user.nsaId ? undefined : monitor.presence_user ?? undefined, }; } @@ -297,11 +301,11 @@ export class PresenceMonitorManager { if (source && 'na_id' in source && existing && existing instanceof EmbeddedPresenceMonitor && - existing.data.user.id === source.na_id && - existing.presence_user !== (source.friend_nsa_id ?? existing.data.nsoAccount.user.nsaId) + existing.user.data.user.id === source.na_id && + existing.presence_user !== (source.friend_nsa_id ?? existing.user.data.nsoAccount.user.nsaId) ) { await this.start(source.na_id, monitor => { - monitor.presence_user = source.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId; + monitor.presence_user = source.friend_nsa_id ?? monitor.user.data.nsoAccount.user.nsaId; this.setDiscordPresenceSourceCopyConfiguration(monitor, existing); callback?.call(null, monitor); monitor.discord.refreshExternalMonitorsConfig(); @@ -314,7 +318,7 @@ export class PresenceMonitorManager { if (existing) { if (source && ( - ('na_id' in source && existing instanceof EmbeddedPresenceMonitor && existing.data.user.id === source.na_id) || + ('na_id' in source && existing instanceof EmbeddedPresenceMonitor && existing.user.data.user.id === source.na_id) || ('url' in source && existing instanceof EmbeddedProxyPresenceMonitor && existing.presence_url === source.url) )) { callback?.call(null, existing); @@ -327,7 +331,7 @@ export class PresenceMonitorManager { existing.presence_user = null; if (!existing.user_notifications && !existing.friend_notifications) { - this.stop(existing.data.user.id); + this.stop(existing.user.data.user.id); } } @@ -338,7 +342,7 @@ export class PresenceMonitorManager { if (source && 'na_id' in source) { await this.start(source.na_id, async monitor => { - monitor.presence_user = source.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId; + monitor.presence_user = source.friend_nsa_id ?? monitor.user.data.nsoAccount.user.nsaId; if (existing) this.setDiscordPresenceSourceCopyConfiguration(monitor, existing); else await this.setDiscordPresenceSourceRestoreSavedConfiguration(monitor); callback?.call(null, monitor); @@ -434,6 +438,14 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence { onError?: (error: ErrorResponse | NodeJS.ErrnoException) => Promise | LoopResult | void = undefined; + constructor( + user: CoralUser, + storage: LocalStorage, + readonly token: string, + ) { + super(user, storage); + } + enable() { if (this._running !== 0) return; this._run(); @@ -468,7 +480,7 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence { await this.onStop?.(); - debug('Monitor for user %s finished', this.data.nsoAccount.user.name); + debug('Monitor for user %s finished', this.user.data.nsoAccount.user.name); } finally { this._running = 0; } diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 3aad38b..2854501 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -24,6 +24,7 @@ declare global { namespace Express { interface Request { coralUser?: CoralUser; + coralNaSessionToken?: string; coral?: CoralApi; coralAuthData?: SavedToken; @@ -169,6 +170,10 @@ class Server extends HttpServer { this.createProxyRequestHandler(r => this.handleWebServiceTokenRequest(r, r.req.params.id), true)); app.get('/api/znc/activeevent', this.authTokenMiddleware, this.localAuthMiddleware, this.createProxyRequestHandler(r => this.handleActiveEventRequest(r))); + app.get('/api/znc/chats', this.authTokenMiddleware, this.localAuthMiddleware, + this.createProxyRequestHandler(r => this.handleChatsRequest(r))); + app.get('/api/znc/media', this.authTokenMiddleware, this.localAuthMiddleware, + this.createProxyRequestHandler(r => this.handleMediaRequest(r))); app.get('/api/znc/event/:id', this.createProxyRequestHandler(r => this.handleEventRequest(r, r.req.params.id), true)); @@ -285,6 +290,8 @@ class Server extends HttpServer { na_session_token = auth.substr(3); } + req.coralNaSessionToken = na_session_token; + let user_naid: string | null = null; const promise = this.coral_auth_promise.get(na_session_token) ?? (async () => { @@ -326,7 +333,7 @@ class Server extends HttpServer { async handleAuthRequest({user}: RequestDataWithUser) { if (user.nso instanceof ZncProxyApi) { - return user.nso.fetchProxyApi('/auth'); + return user.nso.fetchProxyApi('auth'); } else { return user.data; } @@ -397,7 +404,7 @@ class Server extends HttpServer { async handleApiCallRequest({req, policy}: RequestData) { if (policy && !policy.api) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const flags: Partial = {}; @@ -443,12 +450,12 @@ class Server extends HttpServer { // // Announcements - // This is cached for all users. + // This is cached permanently per-user, although other requests may cause this to be updated. // async handleAnnouncementsRequest({req, policy}: RequestData) { if (policy && !policy.announcements) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); @@ -461,22 +468,15 @@ class Server extends HttpServer { // Nintendo Switch user data // - private user_data_promise = new Map>(); - private cached_userdata = new Map(); - - async getUserData(id: string, coral: CoralApiInterface) { - return this._cache(id, () => coral.getCurrentUser(), - this.user_data_promise, this.cached_userdata); - } - async handleCurrentUserRequest({req, res, policy}: RequestData) { if (policy && !policy.current_user) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); - const [updated, current_user] = await this.getUserData(user.data.user.id, user.nso); + const current_user = await user.getCurrentUser(); + const updated = user.updated.user; res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); return {user: current_user, updated}; @@ -484,12 +484,13 @@ class Server extends HttpServer { async handleUserPresenceRequest({req, policy}: RequestData) { if (policy && !policy.current_user_presence) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); - const [updated, current_user] = await this.getUserData(user.data.user.id, user.nso); + const current_user = await user.getCurrentUser(); + const updated = user.updated.user; return current_user.presence; } @@ -500,7 +501,7 @@ class Server extends HttpServer { async handleFriendsRequest({req, res, policy}: RequestData) { if (policy && !policy.list_friends) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); @@ -522,7 +523,7 @@ class Server extends HttpServer { async handleFavouriteFriendsRequest({req, res, policy}: RequestData) { if (policy && !policy.list_friends) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); @@ -544,7 +545,7 @@ class Server extends HttpServer { async handleFriendsPresenceRequest({req, res, policy}: RequestData) { if (policy && !policy.list_friends_presence) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); @@ -568,7 +569,7 @@ class Server extends HttpServer { async handleFavouriteFriendsPresenceRequest({req, res, policy}: RequestData) { if (policy && !policy.list_friends_presence) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); @@ -594,10 +595,10 @@ class Server extends HttpServer { async handleFriendRequest({req, res, policy}: RequestData, nsaid: string) { if (policy && !policy.friend) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } if (policy?.friends && !policy.friends.includes(nsaid)) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); @@ -644,10 +645,10 @@ class Server extends HttpServer { async handleFriendPresenceRequest({req, res, policy}: RequestData, nsaid: string) { if (policy && !policy.friend_presence) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } if (!(policy?.friends_presence?.includes(nsaid) ?? policy?.friends?.includes(nsaid) ?? true)) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); @@ -666,16 +667,12 @@ class Server extends HttpServer { async handleWebServicesRequest({req, res, policy}: RequestData) { if (policy && !policy.webservices) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); - const [friends, webservices, activeevent] = await Promise.all([ - user.getFriends(), - user.getWebServices(), - user.getActiveEvent(), - ]); + const webservices = await user.getWebServices(); const updated = user.updated.webservices; res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); @@ -690,22 +687,46 @@ class Server extends HttpServer { async handleActiveEventRequest({req, res, policy}: RequestData) { if (policy && !policy.activeevent) { - throw new ResponseError(403, 'token_unauthorised'); + throw new ResponseError(403, 'insufficient_scope'); } const user = await this.getCoralUser(req); - const [friends, webservices, activeevent] = await Promise.all([ - user.getFriends(), - user.getWebServices(), - user.getActiveEvent(), - ]); - const updated = user.updated.webservices; + const activeevent = await user.getActiveEvent(); + const updated = user.updated.active_event; res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); return {activeevent, updated}; } + async handleChatsRequest({req, res, policy}: RequestData) { + if (policy && !policy.chats) { + throw new ResponseError(403, 'insufficient_scope'); + } + + const user = await this.getCoralUser(req); + + const chats = await user.getChats(); + const updated = user.updated.chats; + + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return {chats, updated}; + } + + async handleMediaRequest({req, res, policy}: RequestData) { + if (policy && !policy.media) { + throw new ResponseError(403, 'insufficient_scope'); + } + + const user = await this.getCoralUser(req); + + const media = await user.getMedia(); + const updated = user.updated.media; + + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return {media, updated}; + } + async handleEventRequest({user}: RequestDataWithUser, id: string) { const event = await user.nso.getEvent(parseInt(id)); @@ -799,8 +820,7 @@ class Server extends HttpServer { // async handlePresenceEventStreamRequest({req, res, user}: RequestDataWithUser) { - const na_session_token = req.headers['authorization']!.substr(3); - const i = new ZncNotifications(this.storage, na_session_token, user.nso, user.data, user); + const i = new ZncNotifications(user); i.user_notifications = false; i.friend_notifications = true; @@ -815,7 +835,7 @@ class Server extends HttpServer { while (!res.destroyed) { await i.loop(); - this.resetAuthTimeout(na_session_token, () => user.data.user.id); + this.resetAuthTimeout(req.coralNaSessionToken!, () => user.data.user.id); } } catch (err) { stream.sendErrorEvent(err); diff --git a/src/cli/nso/notify.ts b/src/cli/nso/notify.ts index a66e55f..f91ae4d 100644 --- a/src/cli/nso/notify.ts +++ b/src/cli/nso/notify.ts @@ -8,6 +8,7 @@ import { getToken } from '../../common/auth/coral.js'; import { getIksmToken } from '../../common/auth/splatnet2.js'; import { EmbeddedSplatNet2Monitor, NotificationManager, ZncNotifications } from '../../common/notify.js'; import { CurrentUser, Friend, PresenceGame } from '../../api/coral-types.js'; +import Users from '../../common/users.js'; const debug = createDebug('cli:nso:notify'); @@ -100,9 +101,13 @@ export async function handler(argv: ArgumentsCamelCase) { const usernsid = argv.user ?? await storage.getItem('SelectedUser'); const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid); - const {nso, data} = await getToken(storage, token, argv.zncProxyUrl); - const i = new ZncNotifications(storage, token, nso, data); + const users = Users.coral(storage, argv.zncProxyUrl); + const user = await users.get(token); + + const data = user.data; + + const i = new ZncNotifications(user); i.notifications = await TerminalNotificationManager.create(); i.user_notifications = argv.userNotifications; diff --git a/src/cli/nso/presence.ts b/src/cli/nso/presence.ts index 763e460..0f35b9b 100644 --- a/src/cli/nso/presence.ts +++ b/src/cli/nso/presence.ts @@ -7,6 +7,7 @@ import { DiscordPresencePlayTime } from '../../discord/types.js'; import { handleEnableSplatNet2Monitoring, TerminalNotificationManager } from './notify.js'; import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js'; import SplatNet3Monitor, { getConfigFromArgv as getSplatNet3MonitorConfigFromArgv } from '../../discord/monitor/splatoon3.js'; +import Users from '../../common/users.js'; const debug = createDebug('cli:nso:presence'); const debugProxy = createDebug('cli:nso:presence:proxy'); @@ -199,9 +200,13 @@ export async function handler(argv: ArgumentsCamelCase) { const usernsid = argv.user ?? await storage.getItem('SelectedUser'); const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid); - const {nso, data} = await getToken(storage, token, argv.zncProxyUrl); - const i = new ZncDiscordPresence(storage, token, nso, data); + const users = Users.coral(storage, argv.zncProxyUrl); + const user = await users.get(token); + + const data = user.data; + + const i = new ZncDiscordPresence(user, storage); i.notifications = await TerminalNotificationManager.create(); i.user_notifications = argv.userNotifications; diff --git a/src/common/notify.ts b/src/common/notify.ts index 13a8932..00ef669 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -1,7 +1,6 @@ import persist from 'node-persist'; import { CoralApiInterface } from '../api/coral.js'; -import { ActiveEvent, CurrentUser, Friend, Presence, PresenceState, CoralError, GetActiveEventResult, FriendRouteChannel, PresenceGame, Announcements_4, Friend_4, WebServices_4, PresenceOnline_4, PresenceOffline } from '../api/coral-types.js'; -import ZncProxyApi from '../api/znc-proxy.js'; +import { CurrentUser, Friend, Presence, PresenceState, CoralError, PresenceGame } from '../api/coral-types.js'; import { ErrorResponse } from '../api/util.js'; import { SavedToken } from './auth/coral.js'; import { SplatNet2RecordsMonitor } from './splatnet2/monitor.js'; @@ -24,123 +23,28 @@ export class ZncNotifications extends Loop { update_interval = 30; constructor( - public storage: persist.LocalStorage, - public token: string, - public nso: CoralApiInterface, - public data: Omit, - public user?: CoralUser, + public user: CoralUser, ) { super(); } - async fetch(req: ( - 'announcements' | 'friends' | {friend: string; presence?: boolean} | 'webservices' | - 'event' | 'chats' | 'media' | 'user' | null - )[]) { - const result: Partial<{ - announcements: Announcements_4; - friends: Friend_4[]; - webservices: WebServices_4; - activeevent: ActiveEvent; - user: CurrentUser; - }> = {}; - - const friends = req.filter(r => typeof r === 'object' && r && 'friend' in r) as - {friend: string; presence?: boolean}[]; - - if (!(this.nso instanceof ZncProxyApi)) { - if (req.includes('announcements')) req.push('webservices'); - if (req.includes('webservices')) req.push('announcements'); - if (req.includes('event')) req.push('friends', 'webservices', 'chats', 'media', 'announcements', 'user'); - if (req.includes('user')) req.push('friends', 'webservices', 'chats', 'media', 'announcements', 'event'); - if (req.includes('chats')) req.push('friends', 'webservices', 'media', 'announcements', 'event', 'user'); - } - - if (req.includes('announcements')) { - result.announcements = this.user ? - await this.user?.getAnnouncements() : - await this.nso.getAnnouncements(); - } - if (req.includes('friends') || (friends && !(this.nso instanceof ZncProxyApi))) { - result.friends = this.user ? - await this.user.getFriends() : - (await this.nso.getFriendList()).friends; - } else if (friends && this.nso instanceof ZncProxyApi) { - result.friends = await Promise.all(friends.map(async r => { - const nso = this.nso as unknown as ZncProxyApi; - - if (r.presence) { - const friend: Friend_4 = { - id: 0, - nsaId: r.friend, - imageUri: '', - image2Uri: '', - name: '', - isFriend: true, - isFavoriteFriend: false, - isServiceUser: false, - isNew: false, - friendCreatedAt: 0, - route: { - appName: '', - userName: '', - shopUri: '', - imageUri: '', - channel: FriendRouteChannel.FRIEND_CODE, - }, - isOnlineNotificationEnabled: false, - presence: await nso.fetchProxyApi('/friend/' + r.friend + '/presence'), - }; - - return friend; - } - - return (await nso.fetchProxyApi<{friend: Friend_4}>('/friend/' + r.friend)).friend; - })); - } - if (req.includes('webservices')) { - result.webservices = this.user ? - await this.user.getWebServices() : - await this.nso.getWebServices(); - } - if (req.includes('event')) { - const activeevent: GetActiveEventResult = this.user ? - await this.user.getActiveEvent() : - await this.nso.getActiveEvent(); - result.activeevent = 'id' in activeevent ? activeevent as ActiveEvent : undefined; - } - if (req.includes('user')) { - result.user = await this.nso.getCurrentUser(); - } - - return result; - } - async init() { - const {friends, user} = await this.fetch([ - 'announcements', - this.user_notifications ? 'user' : null, - this.friend_notifications ? 'friends' : null, - this.splatnet2_monitors.size ? 'user' : null, - ]); - - await this.updatePresenceForNotifications(user, friends, this.data.user.id, true); - if (user) await this.updatePresenceForSplatNet2Monitors([user]); + await this.update(); return LoopResult.OK; } async updateFriendsStatusForNotifications( friends: (CurrentUser | Friend)[], - naid = this.data.user.id, + naid = this.user.data.user.id, initialRun?: boolean ) { this.notifications.updateFriendsStatusForNotifications(friends, naid, initialRun); } async updatePresenceForNotifications( - user: CurrentUser | undefined, friends: Friend[] | undefined, - naid = this.data.user.id, initialRun?: boolean + user: CurrentUser | null, friends: Friend[] | null, + naid = this.user.data.user.id, initialRun?: boolean ) { await this.updateFriendsStatusForNotifications(([] as (CurrentUser | Friend)[]) .concat(this.user_notifications && user ? [user] : []) @@ -182,13 +86,12 @@ export class ZncNotifications extends Loop { } async update() { - const {friends, user} = await this.fetch([ - this.user_notifications ? 'user' : null, - this.friend_notifications ? 'friends' : null, - this.splatnet2_monitors.size ? 'user' : null, + const [user, friends] = await Promise.all([ + this.user_notifications || this.splatnet2_monitors.size ? this.user.getCurrentUser() : null, + this.friend_notifications ? this.user.getFriends() : null, ]); - await this.updatePresenceForNotifications(user, friends, this.data.user.id, false); + await this.updatePresenceForNotifications(user, friends, this.user.data.user.id, false); if (user) await this.updatePresenceForSplatNet2Monitors([user]); } diff --git a/src/common/presence.ts b/src/common/presence.ts index f8ff362..85bfe03 100644 --- a/src/common/presence.ts +++ b/src/common/presence.ts @@ -1,19 +1,22 @@ import { setTimeout } from 'node:timers'; import { errors } from 'undici'; -import EventSource, { ErrorEvent, EventSourceErrorResponse } from '../util/eventsource.js'; -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, ZncNotifications } from './notify.js'; -import { ActiveEvent, CurrentUser, Friend, Game, PresenceState, CoralError, PresenceOnline_4, PresenceOffline, PresenceOnline } from '../api/coral-types.js'; -import { getPresenceFromUrl } from '../api/znc-proxy.js'; +import { LocalStorage } from 'node-persist'; import createDebug from '../util/debug.js'; -import { ErrorResponse, ResponseSymbol } from '../api/util.js'; import Loop, { LoopResult } from '../util/loop.js'; import { parseLinkHeader } from '../util/http.js'; import { getUserAgent } from '../util/useragent.js'; import { getTitleIdFromEcUrl, TemporaryErrorSymbol } from '../util/misc.js'; import { handleError } from '../util/errors.js'; +import EventSource, { ErrorEvent, EventSourceErrorResponse } from '../util/eventsource.js'; +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 { CoralApiInterface } from '../api/coral.js'; +import { ActiveEvent, CurrentUser, Friend, Game, PresenceState, CoralError, PresenceOnline_4, PresenceOffline, PresenceOnline } from '../api/coral-types.js'; +import { getPresenceFromUrl } from '../api/znc-proxy.js'; +import { ErrorResponse, ResponseSymbol } from '../api/util.js'; +import { CoralUser } from './users.js'; +import { EmbeddedSplatNet2Monitor, ZncNotifications } from './notify.js'; import { StatusUpdateMonitor, StatusUpdateSourceHandle } from './status.js'; const debug = createDebug('nxapi:nso:presence'); @@ -41,9 +44,9 @@ class ZncDiscordPresenceClient { protected i = 0; last_presence: Presence | null = null; - last_user: CurrentUser | Friend | undefined = undefined; - last_friendcode: CurrentUser['links']['friendCode'] | undefined = undefined; - last_event: ActiveEvent | undefined = undefined; + last_user: CurrentUser | Friend | null = null; + last_friendcode: CurrentUser['links']['friendCode'] | null = null; + last_event: ActiveEvent | null = null; last_activity: DiscordPresence | string | null = null; onUpdateActivity: ((activity: DiscordPresence | null) => void) | null = null; @@ -65,14 +68,14 @@ class ZncDiscordPresenceClient { async updatePresenceForDiscord( presence: Presence | null, - user?: CurrentUser | Friend, - friendcode?: CurrentUser['links']['friendCode'], - activeevent?: ActiveEvent + user?: CurrentUser | Friend | null, + friendcode?: CurrentUser['links']['friendCode'] | null, + activeevent?: ActiveEvent | null, ) { this.last_presence = presence; - this.last_user = user; - this.last_friendcode = friendcode; - this.last_event = activeevent; + this.last_user = user ?? null; + this.last_friendcode = friendcode ?? null; + this.last_event = activeevent ?? null; this.onUpdate?.call(null); @@ -114,14 +117,14 @@ class ZncDiscordPresenceClient { } const presence_context: DiscordPresenceContext = { - friendcode: this.m.show_friend_code ? this.m.force_friend_code ?? friendcode : undefined, - activeevent: this.m.show_active_event ? activeevent : undefined, + friendcode: this.m.show_friend_code ? this.m.force_friend_code ?? friendcode ?? undefined : undefined, + activeevent: this.m.show_active_event ? activeevent ?? undefined : undefined, show_play_time: this.m.show_play_time, znc_discord_presence: this.m, proxy_response: (this.m) instanceof ZncProxyDiscordPresence ? this.m.last_data : undefined, monitors: [...this.monitors.values()], nsaid: this.m.presence_user!, - user, + user: user ?? undefined, platform: 'platform' in presence ? presence.platform : undefined, }; @@ -412,62 +415,30 @@ export class ZncDiscordPresence extends ZncNotifications { readonly discord = new ZncDiscordPresenceClient(this); - async init() { - const {friends, user, activeevent} = await this.fetch([ - 'announcements', - this.presence_user ? - this.presence_user === this.data.nsoAccount.user.nsaId ? 'user' : - {friend: this.presence_user} : null, - this.presence_user && this.presence_user !== this.data.nsoAccount.user.nsaId && - this.show_active_event ? 'event' : null, - this.user_notifications ? 'user' : null, - this.friend_notifications ? 'friends' : null, - this.splatnet2_monitors.size ? 'user' : null, - ]); - - if (this.presence_user) { - if (this.presence_user !== this.data.nsoAccount.user.nsaId) { - const friend = friends!.find(f => f.nsaId === this.presence_user); - - if (!friend) { - throw new Error('User "' + this.presence_user + '" is not friends with this user'); - } - - await this.restorePresenceForTitleUpdateAt(friend.nsaId, friend.presence); - - await this.discord.updatePresenceForDiscord(friend.presence, friend); - await this.savePresenceForTitleUpdateAt(friend.nsaId, friend.presence, this.discord.title?.since); - } else { - await this.restorePresenceForTitleUpdateAt(user!.nsaId, user!.presence); - - await this.discord.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent); - await this.savePresenceForTitleUpdateAt(user!.nsaId, user!.presence, this.discord.title?.since); - } - } - - await this.updatePresenceForNotifications(user, friends, this.data.user.id, true); - if (user) await this.updatePresenceForSplatNet2Monitors([user]); - - return LoopResult.OK; + constructor( + user: CoralUser, + readonly storage: LocalStorage, + ) { + super(user); } + get presence_enabled() { return !!this.presence_user; } async update() { - const {friends, user, activeevent} = await this.fetch([ - this.presence_user ? - this.presence_user === this.data.nsoAccount.user.nsaId ? 'user' : - {friend: this.presence_user} : null, - this.presence_user && this.show_active_event ? 'event' : null, - this.user_notifications ? 'user' : null, - this.friend_notifications ? 'friends' : null, - this.splatnet2_monitors.size ? 'user' : null, + const [user, friends, activeevent] = await Promise.all([ + (this.presence_user && this.presence_user === this.user.data.nsoAccount.user.nsaId) || + this.user_notifications || this.splatnet2_monitors.size ? this.user.getCurrentUser() : null, + (this.presence_user && this.presence_user !== this.user.data.nsoAccount.user.nsaId) || + this.friend_notifications ? this.user.getFriends() : null, + this.presence_user && this.presence_user === this.user.data.nsoAccount.user.nsaId && + this.show_active_event ? this.user.getActiveEvent() : null, ]); if (this.presence_user) { - if (this.presence_user !== this.data.nsoAccount.user.nsaId) { + if (this.presence_user !== this.user.data.nsoAccount.user.nsaId) { const friend = friends!.find(f => f.nsaId === this.presence_user); if (!friend) { @@ -483,7 +454,7 @@ export class ZncDiscordPresence extends ZncNotifications { } } - await this.updatePresenceForNotifications(user, friends, this.data.user.id, false); + await this.updatePresenceForNotifications(user, friends, this.user.data.user.id, false); if (user) await this.updatePresenceForSplatNet2Monitors([user]); } diff --git a/src/common/users.ts b/src/common/users.ts index f77762b..ad3455a 100644 --- a/src/common/users.ts +++ b/src/common/users.ts @@ -243,7 +243,7 @@ export class CoralUser implements CoralU this.active_event = await this.nso.getActiveEvent(); }, this.update_interval); - return this.active_event.result; + return 'id' in this.active_event.result ? this.active_event.result : null; } async getCurrentUser() {