Use detailed presence information from SplatNet 3

This commit is contained in:
Samuel Elliott 2022-09-21 15:47:42 +01:00
parent e5fe9b389e
commit 4e6a29f74f
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
11 changed files with 575 additions and 18 deletions

View File

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

View File

@ -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) {
</Picker>
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>SplatNet 3</Text>
</View>
<View style={styles.sectionRight}>
<View style={[styles.checkboxContainer]}>
<CheckBox
value={discord_options?.monitors?.enable_splatnet3_monitoring ?? false}
onValueChange={setDiscordEnableSplatNet3Monitor}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordEnableSplatNet3Monitor(!discord_options?.monitors?.enable_splatnet3_monitoring)}>
<Text style={[styles.checkboxLabelText, theme.text]}>Enable enhanced Discord Rich Presence for Splatoon 3</Text>
</TouchableOpacity>
</View>
<Text style={[styles.help, theme.text]}>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.</Text>
</View>
</View>
</View>
</Root>;
}

View File

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

View File

@ -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<DiscordPresenceConfiguration, 'source'> | 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<ZncDiscordPresence['discord']['onWillStartMonitor'], null>,
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(

View File

@ -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<ParentArguments>) {
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<ReturnType<typeof builder>>;
export type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
if (argv.presenceUrl) {
@ -217,15 +227,21 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
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);

View File

@ -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<ExternalMonitorConstructor<any>, 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: (<T>(monitor: ExternalMonitorConstructor<T>) => any | T | null | Promise<T | null>) | null = null;
onMonitorError: ((monitor: ExternalMonitorConstructor, instance: ExternalMonitor, error: Error) =>
ErrorResult | Promise<ErrorResult>) | 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<T>(monitor: ExternalMonitorConstructor<T>, 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<T>(monitor: ExternalMonitorConstructor<T>, 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<any>,
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 {

View File

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

View File

@ -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<FriendListResult> | null = null;
cached_schedules: GraphQLResponse<StageScheduleResult> | 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<void> {
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<T extends {startTime: string; endTime: string;}>(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<Arguments>,
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;
}
}
}
}

View File

@ -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<M extends ExternalMonitor = ExternalMonitor> {
/**
* 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<any, M>;
/**
* 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<T = unknown, I extends ExternalMonitor<T> = ExternalMonitor<T>> {
new (discord_presence: ExternalMonitorPresenceInterface, config: T | null, game?: Game): I;
}
export interface ExternalMonitor<T = unknown> 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,
}

View File

@ -67,6 +67,7 @@ export function getDiscordPresence(
return {
id: title.client || defaultTitle.client,
title: titleid,
config: title,
activity,
showTimestamp: title.showTimestamp ?? true,
};

View File

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