diff --git a/src/api/splatnet3-types.ts b/src/api/splatnet3-types.ts index 763cf13..29b3175 100644 --- a/src/api/splatnet3-types.ts +++ b/src/api/splatnet3-types.ts @@ -372,6 +372,46 @@ export interface BattleHistoryCurrentPlayerResult { }; } +/** 7a0e05c28c7d3f7e5a06def87ab8cd2d FriendListQuery */ +export interface FriendListResult { + friends: { + nodes: Friend[]; + }; + currentFest: unknown | null; +} + +/** c1afed6111887347e244c639e7d35c69 FriendListRefetchQuery */ +export type FriendListRefetchResult = FriendListResult; + +interface Friend { + id: string; + onlineState: FriendOnlineState; + nickname: string; + playerName: string | null; + userIcon: { + url: string; + width: number; + height: number; + }; + vsMode: { + id: string; + mode: string; // "BANKARA" + name: string; // "Anarchy Battle" + } | null; + isFavorite: boolean; + isLocked: boolean | null; + isVcEnabled: boolean | null; +} + +export enum FriendOnlineState { + OFFLINE = 'OFFLINE', + ONLINE = 'ONLINE', + VS_MODE_MATCHING = 'VS_MODE_MATCHING', + COOP_MODE_MATCHING = 'COOP_MODE_MATCHING', + VS_MODE_FIGHTING = 'VS_MODE_FIGHTING', + COOP_MODE_FIGHTING = 'COOP_MODE_FIGHTING', +} + /** 29957cf5d57b893934de857317cd46d8 HistoryRecordQuery */ export interface HistoryRecordResult { currentPlayer: CurrentPlayer; @@ -638,8 +678,9 @@ export interface HomeResult { }; }; banners: HomeBanner[]; + /** Only includes online friends */ friends: { - nodes: unknown[]; + nodes: HomeFriend[]; totalCount: number; }; footerMessages: unknown[]; @@ -655,6 +696,16 @@ interface HomeBanner { jumpTo: string; } +interface HomeFriend { + id: string; + nickname: string; + userIcon: { + height: number; + url: string; + width: number; + }; +} + /** 994cf141e55213e6923426caf37a1934 VsHistoryDetailPagerRefetchQuery */ export interface VsHistoryDetailPagerRefetchQueryResult { vsHistoryDetail: { diff --git a/src/api/splatnet3.ts b/src/api/splatnet3.ts index 68bcbe2..1928730 100644 --- a/src/api/splatnet3.ts +++ b/src/api/splatnet3.ts @@ -5,7 +5,7 @@ import { NintendoAccountUser } from './na.js'; import { defineResponse, ErrorResponse } from './util.js'; import CoralApi from './coral.js'; import { timeoutSignal } from '../util/misc.js'; -import { BankaraBattleHistoriesResult, BattleHistoryCurrentPlayerResult, BulletToken, CurrentFestResult, GraphQLRequest, GraphQLResponse, HistoryRecordResult, HomeResult, LatestBattleHistoriesResult, PrivateBattleHistoriesResult, RegularBattleHistoriesResult, RequestId, SettingResult, StageScheduleResult, VsHistoryDetailResult } from './splatnet3-types.js'; +import { BankaraBattleHistoriesResult, BattleHistoryCurrentPlayerResult, BulletToken, CurrentFestResult, FriendListResult, GraphQLRequest, GraphQLResponse, HistoryRecordResult, HomeResult, LatestBattleHistoriesResult, PrivateBattleHistoriesResult, RegularBattleHistoriesResult, RequestId, SettingResult, StageScheduleResult, VsHistoryDetailResult } from './splatnet3-types.js'; const debug = createDebug('nxapi:api:splatnet3'); @@ -90,6 +90,14 @@ export default class SplatNet3Api { return this.persistedQuery(RequestId.SettingQuery, {}); } + async getFriends() { + return this.persistedQuery(RequestId.FriendListQuery, {}); + } + + async getFriendsRefetch() { + return this.persistedQuery(RequestId.FriendListRefetchQuery, {}); + } + async getHistoryRecords() { return this.persistedQuery(RequestId.HistoryRecordQuery, {}); } diff --git a/src/cli/splatnet3/friends.ts b/src/cli/splatnet3/friends.ts new file mode 100644 index 0000000..16ffb84 --- /dev/null +++ b/src/cli/splatnet3/friends.ts @@ -0,0 +1,98 @@ +import createDebug from 'debug'; +import Table from '../util/table.js'; +import type { Arguments as ParentArguments } from '../splatnet3.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; +import { initStorage } from '../../util/storage.js'; +import { getBulletToken } from '../../common/auth/splatnet3.js'; +import { FriendOnlineState } from '../../api/splatnet3-types.js'; + +const debug = createDebug('cli:splatnet3:friends'); + +export const command = 'friends'; +export const desc = 'List Nintendo Switch Online friends who have played Splatoon 3'; + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).option('json', { + describe: 'Output raw JSON', + type: 'boolean', + }).option('json-pretty-print', { + describe: 'Output pretty-printed JSON', + type: 'boolean', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + const storage = await initStorage(argv.dataPath); + + const usernsid = argv.user ?? await storage.getItem('SelectedUser'); + const token: string = argv.token || + await storage.getItem('NintendoAccountToken.' + usernsid); + const {splatnet} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const friends = await splatnet.getFriends(); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify({friends: friends.data.friends.nodes}, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify({friends: friends.data.friends.nodes})); + return; + } + + const table = new Table({ + head: [ + 'NSA ID', + 'Name', + 'Status', + 'Favourite?', + 'Locked?', + 'Voice chat?', + ], + }); + + for (const friend of friends.data.friends.nodes) { + const match = Buffer.from(friend.id, 'base64').toString().match(/^Friend-([0-9a-f]{16})$/); + if (!match) table.options.head[0] = 'ID'; + const id_str = match ? match[1] : friend.id; + + table.push([ + id_str, + friend.playerName === friend.nickname ? friend.playerName : + friend.playerName ? friend.playerName + ' (' + friend.nickname + ')' : + friend.nickname, + getStateDescription(friend.onlineState, friend.vsMode?.name), + friend.isFavorite ? 'Yes' : 'No', + typeof friend.isLocked === 'boolean' ? friend.isLocked ? 'Yes' : 'No' : '-', + typeof friend.isVcEnabled === 'boolean' ? friend.isVcEnabled ? 'Yes' : 'No' : '-', + ]); + } + + console.log(table.toString()); +} + +function getStateDescription(state: FriendOnlineState, vs_mode_desc?: string) { + switch (state) { + case FriendOnlineState.OFFLINE: + return 'Offline'; + case FriendOnlineState.ONLINE: + return 'Online'; + case FriendOnlineState.VS_MODE_MATCHING: + return 'In lobby (' + (vs_mode_desc ?? 'VS') + ')'; + case FriendOnlineState.COOP_MODE_MATCHING: + return 'In lobby (Salmon Run)'; + case FriendOnlineState.VS_MODE_FIGHTING: + return 'In game (' + (vs_mode_desc ?? 'VS') + ')'; + case FriendOnlineState.COOP_MODE_FIGHTING: + return 'In game (Salmon Run)'; + default: return state; + } +} diff --git a/src/cli/splatnet3/index.ts b/src/cli/splatnet3/index.ts index 4a2b7fb..d15bb08 100644 --- a/src/cli/splatnet3/index.ts +++ b/src/cli/splatnet3/index.ts @@ -1,5 +1,6 @@ export * as user from './user.js'; export * as token from './token.js'; +export * as friends from './friends.js'; export * as schedule from './schedule.js'; export * as battles from './battles.js'; export * as dumpResults from './dump-results.js';