diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 0db7596..3bfb81d 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -169,6 +169,7 @@ export enum ZncPresenceEventStreamEvent { FRIEND_OFFLINE = '1', FRIEND_TITLE_CHANGE = '2', FRIEND_TITLE_STATECHANGE = '3', + PRESENCE_UPDATED = '4', } export type PresenceUrlResponse = diff --git a/src/app/main/index.ts b/src/app/main/index.ts index b7030af..55a7782 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -242,7 +242,13 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence { try { return await super.handleError(err); } catch (err) { - if (err instanceof Error) { + if (err instanceof ErrorResponse) { + dialog.showErrorBox('Request error', + err.response.status + ' ' + err.response.statusText + ' ' + + err.response.url + '\n' + + err.body + '\n\n' + + (err.stack ?? err.message)); + } else if (err instanceof Error) { dialog.showErrorBox(err.name, err.stack ?? err.message); } else { dialog.showErrorBox('Error', err as any); @@ -251,6 +257,11 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence { return LoopResult.OK; } } + + skipIntervalInCurrentLoop(start = false) { + super.skipIntervalInCurrentLoop(); + if (!this._running && start) this.enable(); + } } export class ElectronNotificationManager extends NotificationManager { diff --git a/src/app/main/menu.ts b/src/app/main/menu.ts index 3676849..4a5599d 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -59,7 +59,7 @@ export default class MenuApp { {label: 'Enable notifications for this friends of this user\'s presence', type: 'checkbox', checked: monitor?.friend_notifications, click: () => this.setFriendNotificationsActive(data.user.id, !monitor?.friend_notifications)}, - {label: 'Update now', enabled: !!monitor, click: () => monitor?.skipIntervalInCurrentLoop()}, + {label: 'Update now', enabled: !!monitor, click: () => monitor?.skipIntervalInCurrentLoop(true)}, {type: 'separator'}, {label: 'Web services', enabled: false}, ...await this.getWebServiceItems(token) as any, diff --git a/src/cli/android-znca-api-server-frida.ts b/src/cli/android-znca-api-server-frida.ts index 2e1b856..253fd46 100644 --- a/src/cli/android-znca-api-server-frida.ts +++ b/src/cli/android-znca-api-server-frida.ts @@ -14,6 +14,8 @@ import { ZNCA_CLIENT_ID, ZncJwtPayload } from '../api/znc.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js'; import { initStorage, paths } from '../util/storage.js'; import { getJwks, Jwt } from '../util/jwt.js'; +import { product } from '../util/product.js'; +import { parseListenAddress } from '../util/net.js'; const debug = createDebug('cli:android-znca-api-server-frida'); const debugApi = createDebug('cli:android-znca-api-server-frida:api'); @@ -100,13 +102,20 @@ export async function handler(argv: ArgumentsCamelCase) { const app = express(); + app.use('/api/znca', (req, res, next) => { + console.log('[%s] %s %s HTTP/%s from %s, port %d%s, %s', + new Date(), req.method, req.path, 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 + ' android-znca-api-frida'); + + next(); + }); + app.post('/api/znca/f', bodyParser.json(), async (req, res) => { try { - console.log('[%s] %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']); await ready; const data: { @@ -216,10 +225,8 @@ export async function handler(argv: ArgumentsCamelCase) { }); for (const address of argv.listen) { - const match = address.match(/^((?:((?:\d+\.){3}\d+)|\[(.*)\]):)(\d+)$/); - if (!match || (match[1] && !net.isIP(match[2] || match[3]))) throw new Error('Not a valid address/port'); - - const server = app.listen(parseInt(match[4]), match[2] || match[3] || '::'); + const [host, port] = parseListenAddress(address); + const server = app.listen(port, host ?? '::'); server.on('listening', () => { const address = server.address() as net.AddressInfo; console.log('Listening on %s, port %d', address.address, address.port); diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 9b2e389..d5b7b20 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -1,5 +1,6 @@ import * as net from 'node:net'; import createDebug from 'debug'; +import * as persist from 'node-persist'; import express, { Request, Response } from 'express'; import bodyParser from 'body-parser'; import { v4 as uuidgen } from 'uuid'; @@ -9,7 +10,9 @@ import type { Arguments as ParentArguments } from '../nso.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; import { initStorage } from '../../util/storage.js'; import { getToken, SavedToken } from '../../common/auth/nso.js'; -import { NotificationManager, ZncNotifications } from '../../common/notify.js'; +import { NotificationManager, PresenceEvent, ZncNotifications } from '../../common/notify.js'; +import { product } from '../../util/product.js'; +import { parseListenAddress } from '../../util/net.js'; import { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/znc-proxy.js'; declare global { @@ -49,10 +52,26 @@ export function builder(yargs: Argv) { type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { - const updateInterval = argv.updateInterval * 1000; - const storage = await initStorage(argv.dataPath); + const app = createApp(storage, argv.zncProxyUrl, argv.requireToken, argv.updateInterval * 1000); + + for (const address of argv.listen) { + const [host, port] = parseListenAddress(address); + const server = app.listen(port, host ?? '::'); + server.on('listening', () => { + const address = server.address() as net.AddressInfo; + console.log('Listening on %s, port %d', address.address, address.port); + }); + } +} + +function createApp( + storage: persist.LocalStorage, + znc_proxy_url?: string, + require_token = true, + update_interval = 30 * 1000 +) { const app = express(); app.use('/api/znc', (req, res, next) => { @@ -62,11 +81,13 @@ export async function handler(argv: ArgumentsCamelCase) { req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '', req.headers['user-agent']); + res.setHeader('Server', product + ' znc-proxy'); + next(); }); const localAuth: express.RequestHandler = async (req, res, next) => { - if (argv.requireToken || !req.query.user) return next(); + if (require_token || !req.query.user) return next(); const token = await storage.getItem('NintendoAccountToken.' + req.query.user); if (!token) return next(); @@ -105,6 +126,9 @@ export async function handler(argv: ArgumentsCamelCase) { })); } + const znc_auth_promise = new Map>(); + const znc_auth_timeout = new Map(); + const nsoAuth: express.RequestHandler = async (req, res, next) => { try { let nintendoAccountSessionToken: string; @@ -118,15 +142,33 @@ export async function handler(argv: ArgumentsCamelCase) { nintendoAccountSessionToken = auth.substr(3); } - const {nso, data} = await getToken(storage, nintendoAccountSessionToken, argv.zncProxyUrl); + const promise = znc_auth_promise.get(nintendoAccountSessionToken) ?? (async () => { + const auth = await getToken(storage, nintendoAccountSessionToken, znc_proxy_url); + const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); + users.add(auth.data.user.id); + await storage.setItem('NintendoAccountIds', [...users]); + + return auth; + })().catch(err => { + znc_auth_promise.delete(nintendoAccountSessionToken); + clearTimeout(znc_auth_timeout.get(nintendoAccountSessionToken)); + znc_auth_timeout.delete(nintendoAccountSessionToken); + throw err; + }); + znc_auth_promise.set(nintendoAccountSessionToken, promise); + + // Remove the authenticated ZncApi 30 minutes after last use + clearTimeout(znc_auth_timeout.get(nintendoAccountSessionToken)); + znc_auth_timeout.set(nintendoAccountSessionToken, setTimeout(() => { + znc_auth_promise.delete(nintendoAccountSessionToken); + znc_auth_timeout.delete(nintendoAccountSessionToken); + }, 30 * 60 * 1000).unref()); + + const {nso, data} = await promise; req.znc = nso; req.zncAuth = data; - const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); - users.add(data.user.id); - await storage.setItem('NintendoAccountIds', [...users]); - next(); } catch (err) { res.statusCode = 500; @@ -249,20 +291,26 @@ export async function handler(argv: ArgumentsCamelCase) { // Nintendo Switch user data // - const cached_userdata = new Map(); + const user_data_promise = new Map>(); + const cached_userdata = new Map(); const getUserData: express.RequestHandler = async (req, res, next) => { const cache = cached_userdata.get(req.zncAuth!.user.id); - if (cache && ((cache[1] + updateInterval) > Date.now())) { + if (cache && ((cache[1] + update_interval) > Date.now())) { debug('Using cached user data for %s', req.zncAuth!.user.id); next(); return; } try { - const user = await req.znc!.getCurrentUser(); - cached_userdata.set(req.zncAuth!.user.id, [user.result, Date.now()]); + const promise = user_data_promise.get(req.zncAuth!.user.id) ?? req.znc!.getCurrentUser().then(user => { + cached_userdata.set(req.zncAuth!.user.id, [user.result, Date.now()]); + }).finally(() => { + user_data_promise.delete(req.zncAuth!.user.id); + }); + user_data_promise.set(req.zncAuth!.user.id, promise); + await promise; next(); } catch (err) { @@ -282,6 +330,7 @@ export async function handler(argv: ArgumentsCamelCase) { }, localAuth, nsoAuth, getUserData, async (req, res) => { const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!; + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({user, updated})); }); @@ -302,24 +351,28 @@ export async function handler(argv: ArgumentsCamelCase) { // Nintendo Switch friends, NSO app web services, events // - const cached_friendsdata = new Map(); - const cached_appdata = new Map(); + const friends_data_promise = new Map>(); + const cached_friendsdata = new Map(); + const app_data_promise = new Map>(); + const cached_appdata = new Map(); const getFriendsData: express.RequestHandler = async (req, res, next) => { const cache = cached_friendsdata.get(req.zncAuth!.user.id); - if (cache && ((cache[1] + updateInterval) > Date.now())) { + if (cache && ((cache[1] + update_interval) > Date.now())) { debug('Using cached friends data for %s', req.zncAuth!.user.id); next(); return; } try { - const friends = await req.znc!.getFriendList(); - - cached_friendsdata.set(req.zncAuth!.user.id, [ - friends.result.friends, Date.now(), - ]); + const promise = friends_data_promise.get(req.zncAuth!.user.id) ?? req.znc!.getFriendList().then(friends => { + cached_friendsdata.set(req.zncAuth!.user.id, [friends.result.friends, Date.now()]); + }).finally(() => { + friends_data_promise.delete(req.zncAuth!.user.id); + }); + friends_data_promise.set(req.zncAuth!.user.id, promise); + await promise; next(); } catch (err) { @@ -334,23 +387,32 @@ export async function handler(argv: ArgumentsCamelCase) { const getAppData: express.RequestHandler = async (req, res, next) => { const cache = cached_appdata.get(req.zncAuth!.user.id); - if (cache && ((cache[2] + updateInterval) > Date.now())) { + if (cache && ((cache[2] + update_interval) > Date.now())) { debug('Using cached app data for %s', req.zncAuth!.user.id); next(); return; } try { - const friends = await req.znc!.getFriendList(); - const webservices = await req.znc!.getWebServices(); - const activeevent = await req.znc!.getActiveEvent(); + const friends_promise = friends_data_promise.get(req.zncAuth!.user.id) ?? req.znc!.getFriendList().then(friends => { + cached_friendsdata.set(req.zncAuth!.user.id, [friends.result.friends, Date.now()]); + }).finally(() => { + friends_data_promise.delete(req.zncAuth!.user.id); + }); + friends_data_promise.set(req.zncAuth!.user.id, friends_promise); - cached_friendsdata.set(req.zncAuth!.user.id, [ - friends.result.friends, Date.now(), - ]); - cached_appdata.set(req.zncAuth!.user.id, [ - webservices.result, activeevent.result, Date.now(), - ]); + const promise = app_data_promise.get(req.zncAuth!.user.id) ?? Promise.all([ + friends_promise, + req.znc!.getWebServices(), + req.znc!.getActiveEvent(), + ]).then(([friends, webservices, activeevent]) => { + // Friends list was already added to cache + cached_appdata.set(req.zncAuth!.user.id, [webservices.result, activeevent.result, Date.now()]); + }).finally(() => { + app_data_promise.delete(req.zncAuth!.user.id); + }); + app_data_promise.set(req.zncAuth!.user.id, promise); + await promise; next(); } catch (err) { @@ -370,6 +432,7 @@ export async function handler(argv: ArgumentsCamelCase) { }, localAuth, nsoAuth, getFriendsData, async (req, res) => { const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ friends: req.zncAuthPolicy?.friends ? @@ -385,6 +448,7 @@ export async function handler(argv: ArgumentsCamelCase) { }, localAuth, nsoAuth, getFriendsData, async (req, res) => { const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ friends: friends.filter(f => { @@ -414,6 +478,7 @@ export async function handler(argv: ArgumentsCamelCase) { presence[friend.nsaId] = friend.presence; } + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(presence)); }); @@ -438,6 +503,7 @@ export async function handler(argv: ArgumentsCamelCase) { presence[friend.nsaId] = friend.presence; } + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(presence)); }); @@ -461,6 +527,7 @@ export async function handler(argv: ArgumentsCamelCase) { return; } + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({friend, updated})); }); @@ -535,6 +602,7 @@ export async function handler(argv: ArgumentsCamelCase) { return; } + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(friend.presence)); }); @@ -546,6 +614,7 @@ export async function handler(argv: ArgumentsCamelCase) { }, localAuth, nsoAuth, getAppData, async (req, res) => { const [webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({webservices, updated})); }); @@ -575,6 +644,7 @@ export async function handler(argv: ArgumentsCamelCase) { }, localAuth, nsoAuth, getAppData, async (req, res) => { const [webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({activeevent, updated})); }); @@ -632,7 +702,7 @@ export async function handler(argv: ArgumentsCamelCase) { i.user_notifications = false; i.friend_notifications = true; - i.update_interval = argv.updateInterval; + i.update_interval = update_interval / 1000; const es = i.notifications = new EventStreamNotificationManager(req, res); @@ -651,16 +721,11 @@ export async function handler(argv: ArgumentsCamelCase) { } }); - for (const address of argv.listen) { - const match = address.match(/^((?:((?:\d+\.){3}\d+)|\[(.*)\]):)(\d+)$/); - if (!match || (match[1] && !net.isIP(match[2] || match[3]))) throw new Error('Not a valid address/port'); + return app; +} - const server = app.listen(parseInt(match[4]), match[2] || match[3] || '::'); - server.on('listening', () => { - const address = server.address() as net.AddressInfo; - console.log('Listening on %s, port %d', address.address, address.port); - }); - } +function cacheMaxAge(updated_timestamp_ms: number, update_interval_ms: number) { + return Math.floor(((updated_timestamp_ms + update_interval_ms) - Date.now()) / 1000); } class EventStreamNotificationManager extends NotificationManager { @@ -677,6 +742,15 @@ class EventStreamNotificationManager extends NotificationManager { this.res.write('\n'); } + onPresenceUpdated( + friend: CurrentUser | Friend, prev?: CurrentUser | Friend, type?: PresenceEvent, + naid?: string, ir?: boolean + ) { + this.sendEvent(ZncPresenceEventStreamEvent.PRESENCE_UPDATED, { + id: friend.nsaId, presence: friend.presence, prev: prev?.presence, + }); + } + onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) { this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_ONLINE, { id: friend.nsaId, presence: friend.presence, prev: prev?.presence, diff --git a/src/common/notify.ts b/src/common/notify.ts index 5517974..1f376ef 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -184,6 +184,8 @@ export class ZncNotifications extends Loop { } export class NotificationManager { + onPresenceUpdated?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, type?: PresenceEvent, naid?: string, ir?: boolean): void; + onFriendOnline?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean): void; onFriendOffline?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean): void; onFriendPlayingChangeTitle?(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean): void; @@ -216,8 +218,19 @@ export class NotificationManager { // Another account is monitoring this user's presence if (this.accounts.get(friend.nsaId) !== naid) continue; + if (lastpresence?.updatedAt !== friend.presence.updatedAt && !initialRun) { + debug('%s\'s presence updated', friend.name, new Date(friend.presence.updatedAt * 1000).toString()); + } + + let type: PresenceEvent | undefined = undefined; + let callback: 'onFriendOnline' | 'onFriendOffline' | 'onFriendPlayingChangeTitle' | + 'onFriendTitleStateChange' | undefined = undefined; + if (!wasonline && online) { // Friend has come online + type = PresenceEvent.STATE_CHANGE; + callback = 'onFriendOnline'; + const currenttitle = friend.presence.game as Game; debugFriends('%s is now online%s%s, title %s %s - played for %s since %s', friend.name, @@ -227,20 +240,20 @@ export class NotificationManager { hrduration(currenttitle.totalPlayTime), currenttitle.firstPlayedAt ? new Date(currenttitle.firstPlayedAt * 1000).toString() : 'now'); - this.onFriendOnline?.(friend, prev, naid, initialRun); - if (consolewasonline) { // Friend's console was already online } } else if (wasonline && !online) { // Friend has gone offline + type = PresenceEvent.STATE_CHANGE; + callback = 'onFriendOffline'; + const lasttitle = lastpresence.game as Game; debugFriends('%s is now offline%s, was playing title %s %s', friend.name, friend.presence.state !== PresenceState.OFFLINE ? ' (console still online)' : '', - lasttitle.name, JSON.stringify(lasttitle.sysDescription)); - - this.onFriendOffline?.(friend, prev, naid, initialRun); + lasttitle.name, JSON.stringify(lasttitle.sysDescription), + new Date(friend.presence.logoutAt * 1000).toString()); if (friend.presence.state !== PresenceState.OFFLINE) { // Friend's console is still online @@ -252,6 +265,8 @@ export class NotificationManager { if (getTitleIdFromEcUrl(lasttitle.shopUri) !== getTitleIdFromEcUrl(currenttitle.shopUri)) { // Friend is playing a different title + type = PresenceEvent.TITLE_CHANGE; + callback = 'onFriendPlayingChangeTitle'; debugFriends('%s title is now %s %s%s, was playing %s %s%s - played for %s since %s', friend.name, @@ -261,41 +276,44 @@ export class NotificationManager { lastpresence.state === PresenceState.ONLINE ? '' : ' (' + lastpresence.state + ')', hrduration(currenttitle.totalPlayTime), currenttitle.firstPlayedAt ? new Date(currenttitle.firstPlayedAt * 1000).toString() : 'now'); - - this.onFriendPlayingChangeTitle?.(friend, prev, naid, initialRun); - } else if ( - lastpresence.state !== friend.presence.state || - lasttitle.sysDescription !== currenttitle.sysDescription - ) { - // Title state changed + } else if (lasttitle.sysDescription !== currenttitle.sysDescription) { + // Title state changed (presence state may have also changed between online/playing) + type = PresenceEvent.TITLE_STATE_CHANGE; + callback = 'onFriendTitleStateChange'; debugFriends('%s title %s state changed, now %s %s, was %s %s', friend.name, currenttitle.name, friend.presence.state, JSON.stringify(currenttitle.sysDescription), lastpresence.state, JSON.stringify(lasttitle.sysDescription)); - - this.onFriendTitleStateChange?.(friend, prev, naid, initialRun); - } else if ( - lastpresence.state !== friend.presence.state || - lasttitle.sysDescription !== currenttitle.sysDescription - ) { + } else if (lastpresence.state !== friend.presence.state) { // Presence state changed (between online/playing) + type = PresenceEvent.TITLE_STATE_CHANGE; + callback = 'onFriendTitleStateChange'; debugFriends('%s title %s state changed%s, now %s %s, was %s %s', - friend.name, currenttitle.name, - friend.presence.state, JSON.stringify(currenttitle.sysDescription), + friend.name, currenttitle.name, friend.presence.state, lastpresence.state, JSON.stringify(lasttitle.sysDescription)); } } else if (!consolewasonline && friend.presence.state !== PresenceState.OFFLINE) { // Friend's console is now online, but the user is not playing + type = PresenceEvent.STATE_CHANGE; debugFriends('%s\'s console is now online', friend.name); } else if (consolewasonline && friend.presence.state !== PresenceState.OFFLINE) { // Friend's console is still online, the user is still not playing } else if (consolewasonline && friend.presence.state === PresenceState.OFFLINE) { // Friend's console is now offline + type = PresenceEvent.STATE_CHANGE; - debugFriends('%s\'s console is now offline', friend.name); + debugFriends('%s\'s console is now offline', friend.name, + new Date(friend.presence.logoutAt * 1000).toString()); + } + + if (lastpresence?.updatedAt !== friend.presence.updatedAt && !initialRun) { + this.onPresenceUpdated?.(friend, prev, type, naid, initialRun); + } + if (callback) { + this[callback]?.(friend, prev, naid, initialRun); } } @@ -345,6 +363,12 @@ export class NotificationManager { } } +export enum PresenceEvent { + STATE_CHANGE, + TITLE_CHANGE, + TITLE_STATE_CHANGE, +} + export class EmbeddedSplatNet2Monitor extends SplatNet2RecordsMonitor { static title_ids = [ '0100f8f0000a2000', // Europe diff --git a/src/common/presence.ts b/src/common/presence.ts index 58b62e4..047b5be 100644 --- a/src/common/presence.ts +++ b/src/common/presence.ts @@ -46,7 +46,7 @@ export class ZncDiscordPresence extends ZncNotifications { } } - await this.updatePresenceForNotifications(user, friends); + await this.updatePresenceForNotifications(user, friends, this.data.user.id, true); if (user) await this.updatePresenceForSplatNet2Monitors([user]); return LoopResult.OK; @@ -248,7 +248,7 @@ export class ZncDiscordPresence extends ZncNotifications { } } - await this.updatePresenceForNotifications(user, friends); + await this.updatePresenceForNotifications(user, friends, this.data.user.id, true); if (user) await this.updatePresenceForSplatNet2Monitors([user]); } diff --git a/src/discord/util.ts b/src/discord/util.ts index 710b6b3..fed4136 100644 --- a/src/discord/util.ts +++ b/src/discord/util.ts @@ -1,13 +1,10 @@ import DiscordRPC from 'discord-rpc'; import { ActiveEvent, CurrentUser, Friend, Game, PresenceState } from '../api/znc-types.js'; import { defaultTitle, titles } from './titles.js'; -import { dev, git, version } from '../util/product.js'; +import { product } from '../util/product.js'; import { getTitleIdFromEcUrl, hrduration } from '../util/misc.js'; import { ZncDiscordPresence } from '../common/presence.js'; -const product = 'nxapi ' + version + - (git ? '-' + git.revision.substr(0, 7) + (git.branch ? ' (' + git.branch + ')' : dev ? '-dev' : '') : ''); - export function getDiscordPresence( state: PresenceState, game: Game, context?: DiscordPresenceContext ): DiscordPresence { diff --git a/src/util/net.ts b/src/util/net.ts new file mode 100644 index 0000000..8632403 --- /dev/null +++ b/src/util/net.ts @@ -0,0 +1,11 @@ +import * as net from 'node:net'; + +export function parseListenAddress(address: string | number) { + const match = ('' + address).match(/^((?:((?:\d+\.){3}\d+)|\[(.*)\]):)?(\d+)$/); + if (!match || (match[1] && !net.isIP(match[2] || match[3]))) throw new Error('Invalid address/port'); + + const host = match[2] || match[3] || null; + const port = parseInt(match[4]); + + return [host, port] as const; +} diff --git a/src/util/product.ts b/src/util/product.ts index f0c77df..ce5eccb 100644 --- a/src/util/product.ts +++ b/src/util/product.ts @@ -33,3 +33,6 @@ export const git = (() => { }; })(); export const dev = !!git || process.env.NODE_ENV === 'development'; + +export const product = 'nxapi ' + version + + (git ? '-' + git.revision.substr(0, 7) + (git.branch ? ' (' + git.branch + ')' : dev ? '-dev' : '') : '');