mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-03-21 18:04:10 -05:00
Use detailed presence information from SplatNet 3
This commit is contained in:
parent
e5fe9b389e
commit
4e6a29f74f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
265
src/discord/titles/nintendo/splatoon3.ts
Normal file
265
src/discord/titles/nintendo/splatoon3.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export function getDiscordPresence(
|
|||
return {
|
||||
id: title.client || defaultTitle.client,
|
||||
title: titleid,
|
||||
config: title,
|
||||
activity,
|
||||
showTimestamp: title.showTimestamp ?? true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user