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;
+ }
+ }
+}