Presence monitoring

This commit is contained in:
Samuel Elliott 2025-07-25 17:54:18 +01:00
parent eb778f0c8e
commit 4452947187
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
13 changed files with 182 additions and 255 deletions

View File

@ -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';

View File

@ -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 {

View File

@ -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<GetActiveEventResult, typeof result>(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;

View File

@ -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;

View File

@ -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)}),

View File

@ -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();

View File

@ -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<CoralError> | NodeJS.ErrnoException) =>
Promise<LoopResult | void> | LoopResult | void = undefined;
constructor(
user: CoralUser<CoralApiInterface>,
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;
}

View File

@ -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<RequestFlags> = {};
@ -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</** NA ID */ string, Promise<[number, CurrentUser]>>();
private cached_userdata = new Map</** NA ID */ string, [number, CurrentUser]>();
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);

View File

@ -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<Arguments>) {
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;

View File

@ -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<Arguments>) {
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;

View File

@ -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<SavedToken, 'expires_at'>,
public user?: CoralUser<CoralApiInterface>,
public user: CoralUser<CoralApiInterface>,
) {
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<PresenceOnline_4 | PresenceOffline>('/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]);
}

View File

@ -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<CoralApiInterface>,
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]);
}

View File

@ -243,7 +243,7 @@ export class CoralUser<T extends CoralApiInterface = CoralApi> 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() {