diff --git a/src/api/splatnet3-types.ts b/src/api/splatnet3-types.ts index 05f3a0e..1058584 100644 --- a/src/api/splatnet3-types.ts +++ b/src/api/splatnet3-types.ts @@ -510,7 +510,7 @@ interface Friend { height: number; }; vsMode: { - id: string; + id: string; // "VnNNb2RlLTI=" (2 == Anarchy Series), "VnNNb2RlLTUx" (51 == Anarchy Open) mode: string; // "BANKARA" name: string; // "Anarchy Battle" } | null; diff --git a/src/app/browser/preferences/index.tsx b/src/app/browser/preferences/index.tsx index 02ab96f..15c2a9e 100644 --- a/src/app/browser/preferences/index.tsx +++ b/src/app/browser/preferences/index.tsx @@ -65,6 +65,11 @@ export default function Preferences(props: PreferencesProps) { await ipc.setDiscordPresenceOptions({...discord_options, show_play_time}); forceRefreshDiscordOptions(); }, [ipc, discord_options]); + const setDiscordEnableSplatNet3Monitor = useCallback(async (enable_splatnet3_monitoring: boolean | 'mixed') => { + await ipc.setDiscordPresenceOptions({...discord_options, monitors: {...discord_options?.monitors, + enable_splatnet3_monitoring: !!enable_splatnet3_monitoring}}); + forceRefreshDiscordOptions(); + }, [ipc, discord_options]); const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource(); @@ -243,6 +248,26 @@ export default function Preferences(props: PreferencesProps) { + + + + SplatNet 3 + + + + + setDiscordEnableSplatNet3Monitor(!discord_options?.monitors?.enable_splatnet3_monitoring)}> + Enable enhanced Discord Rich Presence for Splatoon 3 + + + Uses SplatNet 3 to retrieve additional presence information while playing Splatoon 3. You must be using a secondary Nintendo Account that is friends with your main account to fetch your presence, and the secondary account must be able to access SplatNet 3. + + ; } diff --git a/src/app/common/types.ts b/src/app/common/types.ts index 347cdc9..630a4ec 100644 --- a/src/app/common/types.ts +++ b/src/app/common/types.ts @@ -32,6 +32,7 @@ export interface DiscordPresenceConfiguration { show_console_online?: boolean; show_active_event?: boolean; show_play_time?: DiscordPresencePlayTime; + monitors?: DiscordPresenceExternalMonitorsConfiguration; } export type DiscordPresenceSource = DiscordPresenceSourceCoral | DiscordPresenceSourceUrl; @@ -42,3 +43,7 @@ export interface DiscordPresenceSourceCoral { export interface DiscordPresenceSourceUrl { url: string; } + +export interface DiscordPresenceExternalMonitorsConfiguration { + enable_splatnet3_monitoring?: boolean; +} diff --git a/src/app/main/monitor.ts b/src/app/main/monitor.ts index 26bec96..cea1b90 100644 --- a/src/app/main/monitor.ts +++ b/src/app/main/monitor.ts @@ -7,9 +7,10 @@ import { NotificationManager } from '../../common/notify.js'; import { LoopResult } from '../../util/loop.js'; import { tryGetNativeImageFromUrl } from './util.js'; import { App } from './index.js'; -import { DiscordPresenceConfiguration, DiscordPresenceSource } from '../common/types.js'; -import { DiscordPresence, DiscordPresencePlayTime } from '../../discord/types.js'; +import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource } from '../common/types.js'; +import { DiscordPresence, DiscordPresencePlayTime, ErrorResult, ExternalMonitor, ExternalMonitorConstructor } from '../../discord/types.js'; import { DiscordRpcClient } from '../../discord/rpc.js'; +import SplatNet3Monitor, { getConfigFromAppConfig as getSplatNet3MonitorConfigFromAppConfig } from '../../discord/titles/nintendo/splatoon3.js'; const debug = createDebug('app:main:monitor'); @@ -43,11 +44,29 @@ export class PresenceMonitorManager { i.friend_notifications = false; i.discord.onUpdateActivity = (presence: DiscordPresence | null) => { - this.app.store.emit('update-discord-presence', presence); + this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null); }; i.discord.onUpdateClient = (client: DiscordRpcClient | null) => { this.app.store.emit('update-discord-user', client?.user ?? null); }; + i.discord.onMonitorError = async (monitor, instance, err) => { + const {response} = await dialog.showMessageBox({ + message: err.name + ' in external monitor ' + monitor.name, + detail: err.stack ?? err.message, + type: 'error', + buttons: ['OK', 'Retry', 'Stop'], + defaultId: 0, + }); + + if (response === 1) { + return ErrorResult.RETRY; + } + if (response === 2) { + return ErrorResult.STOP; + } + + return ErrorResult.IGNORE; + }; i.onError = err => this.handleError(i, err); @@ -106,8 +125,9 @@ export class PresenceMonitorManager { return this.monitors.find(m => m.presence_enabled || m instanceof EmbeddedProxyPresenceMonitor); } - getDiscordPresence() { - return this.getActiveDiscordPresenceMonitor()?.discord.last_activity ?? null; + getDiscordPresence(): DiscordPresence | null { + const presence = this.getActiveDiscordPresenceMonitor()?.discord.last_activity; + return presence ? {...presence, config: undefined} : null; } getActiveDiscordPresenceOptions(): Omit | null { @@ -121,6 +141,7 @@ export class PresenceMonitorManager { show_console_online: monitor.show_console_online, show_active_event: monitor.show_active_event, show_play_time: monitor.show_play_time, + monitors: this.getDiscordExternalMonitorConfiguration(monitor.discord.onWillStartMonitor), }; } @@ -135,6 +156,7 @@ export class PresenceMonitorManager { await this.setDiscordPresenceSource(source, monitor => { this.setDiscordPresenceConfigurationForMonitor(monitor, options); + monitor.discord.refreshExternalMonitorsConfig(); monitor.skipIntervalInCurrentLoop(); }); @@ -153,6 +175,7 @@ export class PresenceMonitorManager { await this.setDiscordPresenceSource(config.source, monitor => { this.setDiscordPresenceConfigurationForMonitor(monitor, config); + monitor.discord.refreshExternalMonitorsConfig(); monitor.skipIntervalInCurrentLoop(); }); @@ -170,6 +193,8 @@ export class PresenceMonitorManager { monitor.show_console_online = config.show_console_online ?? false; if (monitor instanceof ZncDiscordPresence) monitor.show_active_event = config.show_active_event ?? false; monitor.show_play_time = config.show_play_time ?? DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE; + monitor.discord.onWillStartMonitor = config.monitors ? + this.createDiscordExternalMonitorHandler(monitor, config.monitors) : null; } private discord_client_filter_config = new WeakMap< @@ -188,6 +213,29 @@ export class PresenceMonitorManager { return filter ? this.discord_client_filter_config.get(filter) : undefined; } + private discord_external_monitor_config = new WeakMap< + Exclude, + DiscordPresenceExternalMonitorsConfiguration + >(); + + createDiscordExternalMonitorHandler( + presence_monitor: EmbeddedPresenceMonitor | EmbeddedProxyPresenceMonitor, + config: DiscordPresenceExternalMonitorsConfiguration + ) { + const handler: ZncDiscordPresence['discord']['onWillStartMonitor'] = monitor => { + if (!(presence_monitor instanceof EmbeddedPresenceMonitor)) return null; + const {storage, token} = presence_monitor; + if (monitor === SplatNet3Monitor) return getSplatNet3MonitorConfigFromAppConfig(config, storage, token); + return null; + }; + this.discord_external_monitor_config.set(handler, config); + return handler; + } + + getDiscordExternalMonitorConfiguration(handler: ZncDiscordPresence['discord']['onWillStartMonitor']) { + return handler ? this.discord_external_monitor_config.get(handler) : undefined; + } + getDiscordPresenceSource(): DiscordPresenceSource | null { const monitor = this.getActiveDiscordPresenceMonitor(); if (!monitor) return null; @@ -216,6 +264,7 @@ export class PresenceMonitorManager { monitor.presence_user = source.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId; this.setDiscordPresenceSourceCopyConfiguration(monitor, existing); callback?.call(null, monitor); + monitor.discord.refreshExternalMonitorsConfig(); monitor.skipIntervalInCurrentLoop(); }); this.app.store.saveMonitorState(this); @@ -252,6 +301,7 @@ export class PresenceMonitorManager { monitor.presence_user = source.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId; if (existing) this.setDiscordPresenceSourceCopyConfiguration(monitor, existing); callback?.call(null, monitor); + monitor.discord.refreshExternalMonitorsConfig(); monitor.skipIntervalInCurrentLoop(); }); } else if (source && 'url' in source) { @@ -277,6 +327,7 @@ export class PresenceMonitorManager { monitor.show_console_online = existing.show_console_online; if (monitor instanceof ZncDiscordPresence) monitor.show_active_event = existing.show_active_event; monitor.show_play_time = existing.show_play_time; + monitor.discord.onWillStartMonitor = existing.discord.onWillStartMonitor; } async handleError( diff --git a/src/cli/nso/presence.ts b/src/cli/nso/presence.ts index 3187c85..d833c4b 100644 --- a/src/cli/nso/presence.ts +++ b/src/cli/nso/presence.ts @@ -6,6 +6,7 @@ import { getToken } from '../../common/auth/coral.js'; 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/titles/nintendo/splatoon3.js'; const debug = createDebug('cli:nso:presence'); const debugProxy = createDebug('cli:nso:presence:proxy'); @@ -111,13 +112,22 @@ export function builder(yargs: Argv) { default: 3 * 60, }).option('splatnet2-auto-update-session', { alias: ['sn2-auto-update-session'], - describe: 'Automatically obtain and refresh the iksm_session cookie', + describe: 'Automatically obtain and refresh the SplatNet 2 iksm_session cookie', + type: 'boolean', + default: true, + }).option('splatnet3-monitor', { + describe: 'Show additional presence data from SplatNet 3 while playing Splatoon 3', + type: 'boolean', + default: false, + }).option('splatnet3-auto-update-session', { + alias: ['sn3-auto-update-session'], + describe: 'Automatically obtain and refresh the SplatNet 3 access token', type: 'boolean', default: true, }); } -type Arguments = YargsArguments>; +export type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { if (argv.presenceUrl) { @@ -217,15 +227,21 @@ export async function handler(argv: ArgumentsCamelCase) { i.discord_preconnect = argv.discordPreconnect; if (argv.discordUser) i.discord_client_filter = (client, id) => client.user?.id === argv.discordUser; + i.discord.onWillStartMonitor = monitor => { + if (monitor === SplatNet3Monitor) return getSplatNet3MonitorConfigFromArgv(argv, storage, token); + return null; + }; + console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)', data.user.screenName, data.user.nickname, data.nsoAccount.user.name); if (argv.splatnet2Monitor) { - if (argv.friendNsaid) { - console.warn('SplatNet 2 monitoring is enabled, but --friend-nsaid is set. SplatNet 2 records will only be downloaded when the authenticated user is playing Splatoon 2 online, regardless of the --friend-nsaid user.'); - } + // if (argv.friendNsaid) { + // console.warn('SplatNet 2 monitoring is enabled, but --friend-nsaid is set. SplatNet 2 records will only be downloaded when the authenticated user is playing Splatoon 2 online, regardless of the --friend-nsaid user.'); + // } - i.splatnet2_monitors.set(data.nsoAccount.user.nsaId, handleEnableSplatNet2Monitoring(argv, storage, token)); + // i.splatnet2_monitors.set(data.nsoAccount.user.nsaId, handleEnableSplatNet2Monitoring(argv, storage, token)); + console.warn('SplatNet 2 monitoring is not supported when not using --presence-url.'); } await i.loop(true); diff --git a/src/common/presence.ts b/src/common/presence.ts index eeabe5a..8880e08 100644 --- a/src/common/presence.ts +++ b/src/common/presence.ts @@ -1,7 +1,7 @@ import createDebug from 'debug'; import { DiscordRpcClient, findDiscordRpcClient } from '../discord/rpc.js'; import { getDiscordPresence, getInactiveDiscordPresence } from '../discord/util.js'; -import { DiscordPresencePlayTime, DiscordPresenceContext, DiscordPresence } from '../discord/types.js'; +import { DiscordPresencePlayTime, DiscordPresenceContext, DiscordPresence, ExternalMonitorConstructor, ExternalMonitor, ErrorResult } from '../discord/types.js'; import { EmbeddedSplatNet2Monitor, ZncNotifications } from './notify.js'; import { getPresenceFromUrl } from '../api/znc-proxy.js'; import { ActiveEvent, CurrentUser, Friend, Game, Presence, PresenceState, CoralErrorResponse } from '../api/coral-types.js'; @@ -25,6 +25,7 @@ interface SavedPresence { class ZncDiscordPresenceClient { rpc: {client: DiscordRpcClient, id: string} | null = null; title: {id: string; since: number} | null = null; + monitors = new Map, ExternalMonitor>(); protected i = 0; last_presence: Presence | null = null; @@ -35,6 +36,9 @@ class ZncDiscordPresenceClient { last_activity: DiscordPresence | null = null; onUpdateActivity: ((activity: DiscordPresence | null) => void) | null = null; onUpdateClient: ((client: DiscordRpcClient | null) => void) | null = null; + onWillStartMonitor: ((monitor: ExternalMonitorConstructor) => any | T | null | Promise) | null = null; + onMonitorError: ((monitor: ExternalMonitorConstructor, instance: ExternalMonitor, error: Error) => + ErrorResult | Promise) | null = null; update_presence_errors = 0; @@ -60,6 +64,12 @@ class ZncDiscordPresenceClient { (this.m.show_console_online && presence?.state === PresenceState.INACTIVE); if (!presence || !show_presence) { + for (const [monitor, instance] of this.monitors.entries()) { + debug('Stopping monitor %s', monitor.name); + instance.disable(); + this.monitors.delete(monitor); + } + if (this.m.presence_enabled && this.m.discord_preconnect && !this.rpc) { debugDiscord('No presence but Discord preconnect enabled - connecting'); const discordpresence = getInactiveDiscordPresence(PresenceState.OFFLINE, 0); @@ -83,6 +93,7 @@ class ZncDiscordPresenceClient { activeevent: this.m.show_active_event ? activeevent : undefined, show_play_time: this.m.show_play_time, znc_discord_presence: this.m, + monitors: [...this.monitors.values()], nsaid: this.m.presence_user!, user, }; @@ -91,6 +102,8 @@ class ZncDiscordPresenceClient { getDiscordPresence(presence.state, presence.game, presence_context) : getInactiveDiscordPresence(presence.state, presence.logoutAt, presence_context); + const prev_title = this.title; + if (discord_presence.title) { if (discord_presence.title !== this.title?.id) { // Use the timestamp the user's presence was last updated according to Nintendo @@ -109,9 +122,85 @@ class ZncDiscordPresenceClient { this.title = null; } + const monitors = discord_presence.config?.monitor ? [discord_presence.config.monitor] : []; + this.updateExternalMonitors(monitors, 'name' in presence.game ? presence.game : undefined, + prev_title?.id, this.title?.id); + this.setActivity(discord_presence); } + async updateExternalMonitors(monitors: ExternalMonitorConstructor[], game?: Game, prev_title?: string, new_title?: string) { + for (const monitor of monitors) { + const instance = this.monitors.get(monitor); + + if (instance) { + if (prev_title !== new_title) { + instance.onChangeTitle?.(game); + } + } else { + const config = await this.getExternalMonitorConfig(monitor); + debug('Starting monitor %s', monitor.name, config); + + const i = new ExternalMonitorPresenceInterface(monitor, this.m); + const instance = new monitor(i, config, game); + Object.assign(i, {instance}); + this.monitors.set(monitor, instance); + instance.enable(); + } + } + + for (const [monitor, instance] of this.monitors.entries()) { + if (monitors.includes(monitor)) continue; + + debug('Stopping monitor %s', monitor.name); + instance.disable(); + this.monitors.delete(monitor); + } + } + + async getExternalMonitorConfig(monitor: ExternalMonitorConstructor) { + return this.onWillStartMonitor?.call(null, monitor) ?? null; + } + + async refreshExternalMonitorsConfig() { + for (const [monitor, instance] of this.monitors.entries()) { + const config = await this.getExternalMonitorConfig(monitor); + await this.updateExternalMonitorConfig(monitor, config); + } + } + + async updateExternalMonitorConfig(monitor: ExternalMonitorConstructor, config: T) { + const instance = this.monitors.get(monitor); + if (!instance) return; + + debug('Updating monitor %s config', monitor.name, config); + + try { + if (await instance.onUpdateConfig?.(config)) { + debug('Updated monitor %s config', monitor.name); + } else { + await this.forceRestartMonitor(monitor, config /* , game */); + } + } catch (err) { + await this.forceRestartMonitor(monitor, config /* , game */); + } + } + + async forceRestartMonitor(monitor: ExternalMonitorConstructor, config: T, game?: Game) { + const existing = this.monitors.get(monitor); + if (!existing) return; + + debug('Restarting monitor %s', monitor.name); + + existing.disable(); + + const i = new ExternalMonitorPresenceInterface(monitor, this.m); + const instance = new monitor(i, config, game); + Object.assign(i, {instance}); + this.monitors.set(monitor, instance); + instance.enable(); + } + async setActivity(activity: DiscordPresence | string | null) { this.onUpdateActivity?.call(null, typeof activity === 'string' ? null : activity); this.last_activity = typeof activity === 'string' ? null : activity; @@ -239,6 +328,38 @@ class ZncDiscordPresenceClient { this.title = null; } } + + refreshPresence() { + this.updatePresenceForDiscord( + this.last_presence, + this.last_user, + this.last_friendcode, + this.last_event, + ); + } +} + +export class ExternalMonitorPresenceInterface { + readonly instance!: ExternalMonitor; + + constructor( + readonly monitor: ExternalMonitorConstructor, + readonly znc_discord_presence: ZncDiscordPresence | ZncProxyDiscordPresence, + ) {} + + refreshPresence() { + this.znc_discord_presence.discord.refreshPresence(); + } + + async handleError(err: Error) { + debug('Error in external monitor %s', this.monitor.name, err); + + if (this.znc_discord_presence.discord.onMonitorError) { + return await this.znc_discord_presence.discord.onMonitorError.call(null, this.monitor, this.instance, err); + } else { + return ErrorResult.STOP; + } + } } export class ZncDiscordPresence extends ZncNotifications { diff --git a/src/discord/titles/nintendo.ts b/src/discord/titles/nintendo.ts index a259926..2cc2843 100644 --- a/src/discord/titles/nintendo.ts +++ b/src/discord/titles/nintendo.ts @@ -1,4 +1,5 @@ import { Title } from '../types.js'; +import SplatNet3Monitor, { callback as SplatNet3ActivityCallback } from './nintendo/splatoon3.js'; export const titles: Title[] = [ { @@ -1113,8 +1114,8 @@ export const titles: Title[] = [ // Splatoon 3 id: '0100c2500fc20000', client: '967103796134158447', - showPlayingOnline: true, - showActiveEvent: true, + monitor: SplatNet3Monitor, + callback: SplatNet3ActivityCallback, }, { // Splatoon 3: Splatfest World Premiere diff --git a/src/discord/titles/nintendo/splatoon3.ts b/src/discord/titles/nintendo/splatoon3.ts new file mode 100644 index 0000000..560ca16 --- /dev/null +++ b/src/discord/titles/nintendo/splatoon3.ts @@ -0,0 +1,265 @@ +import createDebug from 'debug'; +import persist from 'node-persist'; +import DiscordRPC from 'discord-rpc'; +import { Game } from '../../../api/coral-types.js'; +import { BankaraMatchMode, FriendListResult, FriendOnlineState, GraphQLResponse, StageScheduleResult } from '../../../api/splatnet3-types.js'; +import SplatNet3Api from '../../../api/splatnet3.js'; +import { DiscordPresenceExternalMonitorsConfiguration } from '../../../app/common/types.js'; +import { Arguments } from '../../../cli/nso/presence.js'; +import { getBulletToken, SavedBulletToken } from '../../../common/auth/splatnet3.js'; +import { ExternalMonitorPresenceInterface } from '../../../common/presence.js'; +import { EmbeddedLoop, LoopResult } from '../../../util/loop.js'; +import { ArgumentsCamelCase } from '../../../util/yargs.js'; +import { DiscordPresenceContext, ErrorResult } from '../../types.js'; +import { product } from '../../../util/product.js'; + +const debug = createDebug('nxapi:discord:splatnet3'); + +export default class SplatNet3Monitor extends EmbeddedLoop { + update_interval: number = 3 * 60; // 3 minutes in seconds + + splatnet: SplatNet3Api | null = null; + data: SavedBulletToken | null = null; + + cached_friends: GraphQLResponse | null = null; + cached_schedules: GraphQLResponse | null = null; + + friend: FriendListResult['friends']['nodes'][0] | null = null; + + regular_schedule: StageScheduleResult['regularSchedules']['nodes'][0] | null = null; + anarchy_schedule: StageScheduleResult['bankaraSchedules']['nodes'][0] | null = null; + fest_schedule: StageScheduleResult['festSchedules']['nodes'][0] | null = null; + league_schedule: StageScheduleResult['leagueSchedules']['nodes'][0] | null = null; + x_schedule: StageScheduleResult['xSchedules']['nodes'][0] | null = null; + coop_schedule: StageScheduleResult['coopGroupingSchedule']['regularSchedules']['nodes'][0] | null = null; + + constructor( + readonly discord_presence: ExternalMonitorPresenceInterface, + readonly config: SplatNet3MonitorConfig | null, + ) { + super(); + } + + onUpdateConfig(config: SplatNet3MonitorConfig | null) { + if (!!config !== !!this.config) return false; + + if (config?.storage !== this.config?.storage) return false; + if (config?.na_session_token !== this.config?.na_session_token) return false; + if (config?.znc_proxy_url !== this.config?.znc_proxy_url) return false; + if (config?.allow_fetch_token !== this.config?.allow_fetch_token) return false; + + this.skipIntervalInCurrentLoop(); + + return true; + } + + get friend_nsaid() { + return this.config?.friend_nsaid ?? this.discord_presence.znc_discord_presence.presence_user; + } + + async init(): Promise { + if (!this.config) { + debug('Not enabling SplatNet 3 monitor - not configured'); + return this.disable(); + } + + debug('Started monitor'); + + try { + const {splatnet, data} = await getBulletToken( + this.config.storage, + this.config.na_session_token, + this.config.znc_proxy_url, + this.config.allow_fetch_token, + ); + + this.splatnet = splatnet; + this.data = data; + } catch (err) { + const result = await this.discord_presence.handleError(err as Error); + if (result === ErrorResult.RETRY) return this.init(); + if (result === ErrorResult.STOP) return this.disable(); + } + + const history = await this.splatnet!.getHistoryRecords(); + await this.splatnet!.getConfigureAnalytics(); + await this.splatnet!.getCurrentFest(); + + debug('Authenticated to SplatNet 3 %s - player %s#%s (title %s, first played %s)', this.data!.version, + history.data.currentPlayer.name, + history.data.currentPlayer.nameId, + history.data.currentPlayer.byname, + new Date(history.data.playHistory.gameStartTime).toLocaleString()); + + this.cached_friends = await this.splatnet!.getFriends(); + this.cached_schedules = await this.splatnet!.getSchedules(); + } + + async update() { + const friends = this.cached_friends ?? await this.splatnet?.getFriendsRefetch(); + this.cached_friends = null; + + const friend_id = Buffer.from('Friend-' + this.friend_nsaid).toString('base64'); + const friend = friends?.data.friends.nodes.find(f => f.id === friend_id) ?? null; + + this.friend = friend; + + this.regular_schedule = this.getSchedule(this.cached_schedules?.data.regularSchedules.nodes ?? []); + + if (!this.regular_schedule) { + this.cached_schedules = await this.splatnet!.getSchedules(); + this.regular_schedule = this.getSchedule(this.cached_schedules?.data.regularSchedules.nodes ?? []); + } + + this.anarchy_schedule = this.getSchedule(this.cached_schedules?.data.bankaraSchedules.nodes ?? []); + this.fest_schedule = this.getSchedule(this.cached_schedules?.data.festSchedules.nodes ?? []); + this.league_schedule = this.getSchedule(this.cached_schedules?.data.leagueSchedules.nodes ?? []); + this.x_schedule = this.getSchedule(this.cached_schedules?.data.xSchedules.nodes ?? []); + this.coop_schedule = this.getSchedule(this.cached_schedules?.data.coopGroupingSchedule.regularSchedules.nodes ?? []); + + this.discord_presence.refreshPresence(); + } + + getSchedule(schedules: T[]): T | null { + const now = Date.now(); + + for (const schedule of schedules) { + const start = new Date(schedule.startTime); + const end = new Date(schedule.endTime); + + if (start.getTime() >= now) continue; + if (end.getTime() < now) continue; + + return schedule; + } + + return null; + } + + async handleError(err: Error) { + const result = await this.discord_presence.handleError(err as Error); + if (result === ErrorResult.RETRY) return LoopResult.OK_SKIP_INTERVAL; + + this.friend = null; + this.discord_presence.refreshPresence(); + + if (result === ErrorResult.STOP) this.disable(); + return LoopResult.OK; + } +} + +export interface SplatNet3MonitorConfig { + storage: persist.LocalStorage; + na_session_token: string; + znc_proxy_url?: string; + allow_fetch_token: boolean; + friend_nsaid?: string; +} + +export function getConfigFromArgv( + argv: ArgumentsCamelCase, + storage: persist.LocalStorage, + na_session_token: string, +): SplatNet3MonitorConfig | null { + if (!argv.splatnet3Monitor) return null; + + return { + storage, + na_session_token, + znc_proxy_url: argv.zncProxyUrl, + allow_fetch_token: argv.splatnet3AutoUpdateSession, + }; +} + +export function getConfigFromAppConfig( + config: DiscordPresenceExternalMonitorsConfiguration, + storage: persist.LocalStorage, + na_session_token: string, +): SplatNet3MonitorConfig | null { + if (!config.enable_splatnet3_monitoring) return null; + + return { + storage, + na_session_token, + znc_proxy_url: process.env.ZNC_PROXY_URL, + allow_fetch_token: true, + }; +} + +export function callback(activity: DiscordRPC.Presence, game: Game, context?: DiscordPresenceContext) { + const monitor = context?.monitors?.find(m => m instanceof SplatNet3Monitor) as SplatNet3Monitor | undefined; + if (!monitor?.friend) return; + + // REGULAR, BANKARA, X_MATCH, LEAGUE, PRIVATE, FEST + const mode_image = + monitor.friend.vsMode?.mode === 'REGULAR' ? 'mode-regular-1' : + monitor.friend.vsMode?.mode === 'BANKARA' ? 'mode-anarchy-1' : + monitor.friend.vsMode?.mode === 'FEST' ? 'mode-fest-1' : + monitor.friend.vsMode?.mode === 'LEAGUE' ? 'mode-league-1' : + monitor.friend.vsMode?.mode === 'X_MATCH' ? 'mode-x-1' : + undefined; + + const mode_name = + monitor.friend.vsMode?.mode === 'REGULAR' ? 'Regular Battle' : + monitor.friend.vsMode?.id === 'VnNNb2RlLTI=' ? 'Anarchy Battle (Series)' : // VsMode-2 + monitor.friend.vsMode?.id === 'VnNNb2RlLTUx' ? 'Anarchy Battle (Open)' : // VsMode-51 + monitor.friend.vsMode?.mode === 'BANKARA' ? 'Anarchy Battle' : + monitor.friend.vsMode?.mode === 'FEST' ? 'Splatfest Battle' : + monitor.friend.vsMode?.mode === 'LEAGUE' ? 'League Battle' : + monitor.friend.vsMode?.mode === 'X_MATCH' ? 'X Battle' : + undefined; + + const schedule_setting = + monitor.friend.vsMode?.mode === 'REGULAR' ? monitor.regular_schedule?.regularMatchSetting : + monitor.friend.vsMode?.mode === 'BANKARA' ? + monitor.friend.vsMode.id === 'VnNNb2RlLTI=' ? + monitor.anarchy_schedule?.bankaraMatchSettings.find(s => s.mode === BankaraMatchMode.CHALLENGE) : + monitor.friend.vsMode.id === 'VnNNb2RlLTUx' ? + monitor.anarchy_schedule?.bankaraMatchSettings.find(s => s.mode === BankaraMatchMode.OPEN) : + null : + monitor.friend.vsMode?.mode === 'FEST' ? monitor.regular_schedule?.regularMatchSetting : + monitor.friend.vsMode?.mode === 'LEAGUE' ? monitor.league_schedule?.leagueMatchSetting : + monitor.friend.vsMode?.mode === 'X_MATCH' ? monitor.x_schedule?.xMatchSetting : + null; + + if ((monitor.friend.onlineState === FriendOnlineState.VS_MODE_MATCHING || + monitor.friend.onlineState === FriendOnlineState.VS_MODE_FIGHTING) && monitor.friend.vsMode + ) { + activity.details = + (mode_name ?? monitor.friend.vsMode.name) + + (schedule_setting ? ' - ' + schedule_setting.vsRule.name : '') + + (monitor.friend.onlineState === FriendOnlineState.VS_MODE_MATCHING ? ' (matching)' : ''); + + if (schedule_setting) { + activity.largeImageKey = 'https://fancy.org.uk/api/nxapi/s3/image?' + new URLSearchParams({ + a: schedule_setting.vsStages[0].id, + b: schedule_setting.vsStages[1].id, + v: '2022092104', + }).toString(); + activity.largeImageText = schedule_setting.vsStages.map(s => s.name).join('/') + + ' | ' + product; + } + + activity.smallImageKey = mode_image; + activity.smallImageText = mode_name ?? monitor.friend.vsMode.name; + } + + if (monitor.friend.onlineState === FriendOnlineState.COOP_MODE_MATCHING || + monitor.friend.onlineState === FriendOnlineState.COOP_MODE_FIGHTING + ) { + activity.details = 'Salmon Run' + + (monitor.friend.onlineState === FriendOnlineState.COOP_MODE_MATCHING ? ' (matching)' : ''); + + if (monitor.coop_schedule) { + const coop_stage_image = new URL(monitor.coop_schedule.setting.coopStage.image.url); + const match = coop_stage_image.pathname.match(/^\/resources\/prod\/(.+)$/); + const proxy_stage_image = match ? 'https://splatoon3.ink/assets/splatnet/' + match[1] : null; + + if (proxy_stage_image) { + activity.largeImageKey = proxy_stage_image; + activity.largeImageText = monitor.coop_schedule.setting.coopStage.name + + ' | ' + product; + } + } + } +} diff --git a/src/discord/types.ts b/src/discord/types.ts index e2adfc6..d0dd2ea 100644 --- a/src/discord/types.ts +++ b/src/discord/types.ts @@ -1,12 +1,14 @@ import DiscordRPC from 'discord-rpc'; import { ActiveEvent, CurrentUser, Friend, Game } from '../api/coral-types.js'; -import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../common/presence.js'; +import { ExternalMonitorPresenceInterface, ZncDiscordPresence, ZncProxyDiscordPresence } from '../common/presence.js'; +import { EmbeddedLoop } from '../util/loop.js'; export interface DiscordPresenceContext { friendcode?: CurrentUser['links']['friendCode']; activeevent?: ActiveEvent; show_play_time?: DiscordPresencePlayTime; znc_discord_presence?: ZncDiscordPresence | ZncProxyDiscordPresence; + monitors?: ExternalMonitor[]; nsaid?: string; user?: CurrentUser | Friend; } @@ -14,6 +16,7 @@ export interface DiscordPresenceContext { export interface DiscordPresence { id: string; title: string | null; + config?: Title; activity: DiscordRPC.Presence; showTimestamp?: boolean; } @@ -23,7 +26,7 @@ type SystemDataTitleId = `01000000000008${string}`; type SystemAppletTitleId = `0100000000001${string}`; type ApplicationTitleId = `0100${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`; -export interface Title { +export interface Title { /** * Lowercase hexadecimal title ID. * @@ -90,10 +93,17 @@ export interface Title { */ showPlayTime?: boolean; + /** + * An constructor that will be called to create an ExternalMonitor object that can monitor external data while this title is active. + * + * This does not affect Discord activities itself, but can be accessed by the Discord activity callback, which should then modify the activity to add data retrived using the monitor. + */ + monitor?: ExternalMonitorConstructor; + /** * A function to call to customise the Discord activity. */ - callback?: (activity: DiscordRPC.Presence, game: Game, context?: DiscordPresenceContext) => void; + callback?: (activity: DiscordRPC.Presence, game: Game, context?: DiscordPresenceContext, monitor?: M) => void; } export enum DiscordPresencePlayTime { @@ -110,3 +120,24 @@ export enum DiscordPresencePlayTime { /** "Played for x hours and x minutes since dd/mm/yyyy" */ DETAILED_PLAY_TIME_SINCE, } + +export interface ExternalMonitorConstructor = ExternalMonitor> { + new (discord_presence: ExternalMonitorPresenceInterface, config: T | null, game?: Game): I; +} + +export interface ExternalMonitor extends EmbeddedLoop { + /** + * Called when configuration data is updated. + * This will only happen in the Electron app. + * If returns `true` the configuration was updated, if not defined or returns `false` the monitor will be restarted. + */ + onUpdateConfig?(config: T | null): boolean; + + onChangeTitle?(game?: Game): void; +} + +export enum ErrorResult { + STOP, + RETRY, + IGNORE, +} diff --git a/src/discord/util.ts b/src/discord/util.ts index c7f4e14..2b4095f 100644 --- a/src/discord/util.ts +++ b/src/discord/util.ts @@ -67,6 +67,7 @@ export function getDiscordPresence( return { id: title.client || defaultTitle.client, title: titleid, + config: title, activity, showTimestamp: title.showTimestamp ?? true, }; diff --git a/src/util/loop.ts b/src/util/loop.ts index 6045438..f4a5310 100644 --- a/src/util/loop.ts +++ b/src/util/loop.ts @@ -64,3 +64,44 @@ export enum LoopResult { OK = LoopRunOk as any, OK_SKIP_INTERVAL = LoopRunOkSkipInterval as any, } + +export abstract class EmbeddedLoop extends Loop { + onStop?(): Promise | void; + + enable() { + if (this._running !== 0) return; + this._run(); + } + + disable() { + this._running = 0; + } + + get enabled() { + return this._running !== 0; + } + + private _running = 0; + + private async _run() { + this._running++; + const i = this._running; + + try { + await this.loop(true); + + while (i === this._running) { + await this.loop(); + } + + if (this._running === 0 && !this.onStop) { + // Run one more time after the loop ends + const result = await this.loopRun(); + } + + await this.onStop?.(); + } finally { + this._running = 0; + } + } +}