diff --git a/src/api/splatnet2-types.ts b/src/api/splatnet2-types.ts new file mode 100644 index 0000000..00f90f2 --- /dev/null +++ b/src/api/splatnet2-types.ts @@ -0,0 +1,681 @@ +export interface WebServiceError { + code: string; + message: string; +} + +/** GET /records */ +export interface Records { + challenges: Challenges; + festivals: unknown[]; + records: { + recent_disconnect_count: number; + recent_win_count: number; + stage_stats: unknown; + update_time: number; + recent_lose_count: number; + league_stats: { + team: LeagueStats; + pair: LeagueStats; + }; + fes_results: unknown; + unique_id: string; + weapon_stats: Record; + win_count: number; + lose_count: number; + total_paint_point_octa: number; + player: Player; + start_time: number; + }; +} + +interface Challenges { + next_challenge: Challenge; + archived_challenges_octa: OctaChallenge[]; + total_paint_point: number; + rewards: ChallengeReward[]; + total_paint_point_octa: number; + rewards_octa: ChallengeReward[]; + archived_challenges: Challenge[]; + next_challenge_octa: OctaChallenge; +} +interface Challenge { + image: string; + key: string; + name: string; + paint_points: number; +} +interface OctaChallenge { + key: string; + paint_points: number; + url: null; + url_message: null; + name: string; + is_last: boolean; + image: string; +} +interface ChallengeReward { + id: string; + images: { + thumbnail: string; + url: string; + }[]; + paint_points: number; +} + +interface LeagueStats { + gold_count: number; + bronze_count: number; + silver_count: number; + no_medal_count: number; +} + +interface WeaponStats { + win_meter: number; + total_paint_point: number; + last_use_time: number; + lose_count: number; + weapon: Weapon; + win_count: number; + max_win_meter: number; +} + +interface Weapon { + name: string; + id: string; + thumbnail: string; + sub: SubWeapon; + special: SpecialWeapon; + image: string; +} +interface SubWeapon { + image_b: string; + name: string; + id: string; + image_a: string; +} +interface SpecialWeapon { + image_a: string; + id: string; + name: string; + image_b: string; +} + +interface Player { + clothes: Gear & {kind: 'clothes'}; + star_rank: number; + head_skills: Skills; + nickname: string; + shoes_skills: Skills; + weapon: Weapon; + player_rank: number; + max_league_point_team: number; + shoes: Gear & {kind: 'shoes'}; + clothes_skills: Skills; + udemae_tower: Rank; + udemae_clam: Rank; + udemae_rainmaker: Rank; + player_type: PlayerType; + head: Gear & {kind: 'head'}; + max_league_point_pair: number; + principal_id: string; + udemae_zones: Rank; +} + +interface Gear { + name: string; + brand: Brand; + id: string; + rarity: number; + image: string; + kind: 'clothes' | 'shoes' | 'head'; + thumbnail: string; +} +interface Brand { + image: string; + frequent_skill?: Skill; + name: string; + id: string; +} +interface Skills { + subs: (Skill | null)[]; + main: Skill; +} +interface Skill { + name: string; + id: string; + image: string; +} + +interface Rank { + name: null; + s_plus_number: null; + is_x: boolean; + number: number; + is_number_reached: boolean; +} + +interface PlayerType { + species: PlayerSpecies; + style: PlayerStyle; +} +enum PlayerSpecies { + INKLING = 'inklings', + OCTOLING = 'octolings', +} +enum PlayerStyle { + GIRL = 'girl', + BOY = 'boy', +} + +/** GET /data/stages */ +export interface Stages { + stages: Stage[]; +} + +interface Stage { + image: string; + name: string; + id: string; +} + +/** GET /festivals/active */ +export interface ActiveFestivals { + festivals: unknown[]; +} + +/** GET /timeline */ +export interface Timeline { + onlineshop: { + importance: number; + merchandise: ShopMerchandise; + }; + udemae: { + importance: number; + }; + coop: { + importance: number; + schedule: CoopSchedule; + reward_gear: CoopRewardGear; + }; + stats: { + importance: number; + recents: MatchResults[]; + }; + challenge: { + next_challenge: Challenge; + total_paint_point: number; + importance: number; + last_archived_challenge: Challenge; + }; + schedule: { + schedules: { + gachi: ScheduleItem[]; + league: ScheduleItem[]; + regular: ScheduleItem[]; + }; + importance: number; + }; + weapon_availability: { + availabilities: unknown[]; + importance: number; + }; + fes_winners: { + importance: number; + }; + fes_event_match_result: { + importance: number; + }; + unique_id: string; + download_contents: { + is_available: boolean; + importance: number; + }; +} + +interface ShopMerchandise { + skill: Skill; + end_time: number; + gear: Gear; + id: string; + price: number; + kind: string; +} + +interface CoopSchedule { + weapons: CoopWeapon[]; + stage: { + image: string; + name: string; + }; + start_time: number; + end_time: number; +} + +type CoopWeapon = CoopStandardWeapon | CoopSpecialWeapon; +interface CoopStandardWeapon { + weapon: Omit; + id: string; +} +interface CoopSpecialWeapon { + id: string; + coop_special_weapon: { + image: string; + name: string; + }; +} + +interface CoopRewardGear { + available_time: number; + gear: Gear; +} + +/** GET /nickname_and_icon?id={...} */ +export interface NicknameAndIcons { + nickname_and_icons: NicknameAndIcon[]; +} + +export interface NicknameAndIcon { + thumbnail_url: string; + nickname: string; + nsa_id: string; +} + +/** GET /schedules */ +export interface Schedules { + regular: ScheduleItem[]; + league: ScheduleItem[]; + gachi: ScheduleItem[]; +} + +interface ScheduleItem { + start_time: number; + end_time: number; + stage_b: Stage; + rule: Rule; + id: number; + game_mode: GameMode; + stage_a: Stage; +} + +/** GET /records/hero */ +export interface HeroRecords { + stage_infos: StageInfo[]; + summary: { + weapon_cleared_info: Record; + honor: { + code: string; + name: string; + }; + clear_rate: number; + }; + weapon_map: Record; +} + +interface StageInfo { + clear_weapons: Record; + stage: HeroStage; +} +interface StageCleared { + clear_time: number; + weapon_category: string; + weapon_level: number; +} +interface HeroStage { + id: string; + is_boss: boolean; + area: string; +} +interface HeroWeapon { + category: string; + image: string; +} + +/** GET /x_power_ranking/{season}/summary */ +export interface XPowerRankingSummary { + clam_blitz: XPowerRankingRecords; + rainmaker: XPowerRankingRecords; + tower_control: XPowerRankingRecords; + splat_zones: XPowerRankingRecords; +} + +interface XPowerRankingRecords { + weapon_ranking: null; + season_id: string; + top_rankings_count: number; + top_rankings: XPowerRankingRecordsRanking[]; + status: string; + rule: Rule; + start_time: number; + end_time: number; + my_ranking: null; +} +interface XPowerRankingRecordsRanking { + name: string; + principal_id: string; + weapon: Weapon; + rank: number; + unique_id: string; + x_power: number; + rank_change: null; + cheater: boolean; +} + +/** GET /festivals/pasts */ +export interface PastFestivals { + festivals: Festival[]; + results: FestivalResults[]; +} + +interface Festival { + colors: { + middle: InkColour; + bravo: InkColour; + alpha: InkColour; + }; + festival_id: number; + names: { + bravo_short: string; + bravo_long: string; + alpha_short: string; + alpha_long: string; + }; + images: { + bravo: string; + alpha: string; + panel: string; + }; + times: { + start: number; + announce: number; + end: number; + result: number; + }; + special_stage: { + name: string; + id: string; + image: string; + }; +} + +interface InkColour { + g: number; + a: number; + r: number; + b: number; + css_rgb: string; +} + +type FestivalResults = FestivalResults1 | FestivalResults2; +interface FestivalResults2 { + contribution_alpha: FestivalResultsContribution; + contribution_bravo: FestivalResultsContribution; + rates: { + regular: FestivalResultsRates; + challenge: FestivalResultsRates; + vote: FestivalResultsRates; + }, + festival_version: 2; + summary: { + regular: number; + challenge: number; + total: number; + vote: number; + }; + festival_id: number; +} +interface FestivalResultsContribution { + regular: number; + challenge: number; +} +interface FestivalResultsRates { + alpha: number; + bravo: number; +} +interface FestivalResults1 { + festival_id: number; + summary: { + team: number; + vote: number; + total: number; + solo: number; + }, + festival_version: 1; + rates: { + team: FestivalResultsRates; + solo: FestivalResultsRates; + vote: FestivalResultsRates; + }; +} + +/** GET /league_match_ranking/{league}/ALL */ +export interface LeagueMatchRankings { + start_time: number; + league_type: { + name: string; + key: string; + }; + league_ranking_region: { + code: string; + id: number; + }; + rankings: LeagueMatchRanking[]; + league_id: string; +} + +interface LeagueMatchRanking { + tag_members: LeagueTagMember[]; + point: number; + tag_id: string; + cheater: boolean; + rank: number; +} +interface LeagueTagMember { + weapon: Weapon; + unique_id: string; + principal_id: string; +} + +/** GET /results */ +export interface Results { + results: MatchResults[]; + unique_id: string; + summary: { + kill_count_average: number; + victory_count: number; + count: number; + defeat_count: number; + special_count_average: number; + victory_rate: number; + death_count_average: number; + assist_count_average: number; + }; +} + +interface MatchResults { + battle_number: string; + my_team_percentage: number; + type: string; + start_time: number; + win_meter: number; + player_result: PlayerResult; + rule: Rule; + star_rank: number; + stage: Stage; + other_team_percentage: number; + other_team_result: TeamResult; + weapon_paint_point: number; + player_rank: number; + game_mode: GameMode; + my_team_result: TeamResult; +} +interface PlayerResult { + kill_count: number; + death_count: number; + player: Omit; + special_count: number; + assist_count: number; + sort_score: number; + game_paint_point: number; +} +interface Rule { + multiline_name: string; + name: string; + key: string; +} +interface GameMode { + key: string; + name: string; +} +interface TeamResult { + key: 'victory' | 'defeat'; + name: string; +} + +/** GET /results/1 */ +export interface Result extends MatchResults { + other_team_members: PlayerResult[]; + my_team_members: PlayerResult[]; +} + +/** GET /coop_results */ +export interface CoopResults { + results: CoopResultsResult[]; + summary: { + stats: CoopSummaryResult[]; + card: CoopSummaryCard; + }; + reward_gear: Gear; +} + +interface CoopResultsResult { + grade_point: number; + job_score: number; + my_result: CoopPlayerResult; + grade: CoopGrade; + job_rate: number; + job_id: number; + start_time: number; + boss_counts: Record; + end_time: number; + danger_rate: number; + play_time: number; + player_type: PlayerType; + job_result: CoopJobResult; + schedule: CoopSchedule; + wave_details: CoopWave[]; + kuma_point: number; + grade_point_delta: number; +} + +interface CoopPlayerResult { + special: SpecialWeapon; + name: string; + help_count: number; + golden_ikura_num: number; + dead_count: number; + pid: string; + boss_kill_counts: Record; + weapon_list: CoopWeapon[]; + ikura_num: number; + special_counts: number[]; + player_type: PlayerType; +} +interface CoopGrade { + id: string; + long_name: string; + short_name: string; + name: string; +} +interface CoopBossCount { + boss: CoopBoss; + count: number; +} +interface CoopBoss { + name: string; + key: string; +} +type CoopJobResult = CoopJobResultSuccess | CoopJobResultFailure; +interface CoopJobResultSuccess { + is_clear: true; + failure_reason: null; + failure_wave: null; +} +interface CoopJobResultFailure { + failure_wave: number; + failure_reason: string; + is_clear: false; +} +interface CoopWave { + ikura_num: number; + event_type: CoopWaveType; + golden_ikura_num: number; + golden_ikura_pop_num: number; + water_level: CoopWaveWaterLevel; + quota_num: number; +} +interface CoopWaveType { + name: string; + key: string; +} +interface CoopWaveWaterLevel { + key: string; + name: string; +} + +interface CoopSummaryResult { + schedule: CoopSchedule; + my_ikura_total: number; + kuma_point_total: number; + clear_num: number; + end_time: number; + job_num: number; + team_golden_ikura_total: number; + failure_counts: number[]; + grade: { + id: string; + name: string; + }; + team_ikura_total: number; + help_total: number; + start_time: number; + dead_total: number; + grade_point: number; + my_golden_ikura_total: number; +} +interface CoopSummaryCard { + kuma_point_total: number; + golden_ikura_total: number; + help_total: number; + ikura_total: number; + kuma_point: number; + job_num: number; +} + +/** GET /coop_results/1 */ +export interface CoopResult extends CoopResultsResult { + other_results: CoopPlayerResult[]; +} + +/** GET /coop_schedules */ +export interface CoopSchedules { + details: CoopSchedule[]; + schedules: CoopScheduleTimes[]; +} + +interface CoopScheduleTimes { + start_time: number; + end_time: number; +} + +/** GET /onlineshop/merchandises */ +export interface ShopMerchandises { + merchandises: ShopMerchandise[]; + ordered_info: null; +} + +/** POST /share/profile, POST /share/results/summary, POST /share/results/1 */ +export interface ShareResponse { + url: string; + hashtags: string[]; + text: string; +} diff --git a/src/api/splatnet2.ts b/src/api/splatnet2.ts new file mode 100644 index 0000000..374b7db --- /dev/null +++ b/src/api/splatnet2.ts @@ -0,0 +1,333 @@ +import fetch from 'node-fetch'; +import createDebug from 'debug'; +import { v4 as uuidgen } from 'uuid'; +import { WebServiceToken } from './znc-types.js'; +import { NintendoAccountUser } from './na.js'; +import { ErrorResponse } from './util.js'; +import ZncApi from './znc.js'; +import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingSummary } from './splatnet2-types.js'; + +const debug = createDebug('api:splatnet2'); + +export const SPLATNET2_WEBSERVICE_ID = '5741031244955648'; +export const SPLATNET2_WEBSERVICE_URL = 'https://app.splatoon2.nintendo.net/'; +export const SPLATNET2_WEBSERVICE_USERAGENT = 'Mozilla/5.0 (Linux; Android 8.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.125 Mobile Safari/537.36'; + +const SPLATNET2_URL = SPLATNET2_WEBSERVICE_URL + 'api'; + +const XPOWERRANKING_SEASON = /^(\d{2})(\d{2})01T00_(\d{2})(\d{2})01T00$/; +const LEAGUE_ID = /^(\d{2})(\d{2})(\d{2})(\d{2})(T|P)$/; + +export interface SavedIksmSessionToken { + webserviceToken: WebServiceToken; + url: string; + cookies: string; + + iksm_session: string; + expires_at: number; + useragent: string; +} + +export default class SplatNet2Api { + constructor( + public iksm_session: string, + public useragent: string + ) {} + + async fetch(url: string, method = 'GET', body?: string | FormData, headers?: object) { + const response = await fetch(SPLATNET2_URL + url, { + method: method, + headers: Object.assign({ + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': this.useragent, + 'Cookie': 'iksm_session=' + encodeURIComponent(this.iksm_session), + 'dnt': '1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-GB,en-US;q=0.8', + 'X-Requested-With': 'com.nintendo.znca', + }, headers), + body: body, + }); + + debug('fetch %s %s, response %s', method, url, response.status); + + if (response.status !== 200) { + const data = response.headers.get('Content-Type')?.match(/\bapplication\/json\b/i) ? + await response.json() : await response.text(); + + throw new ErrorResponse('[splatnet2] Unknown error', response, data); + } + + const data = await response.json() as T | WebServiceError; + + if ('code' in data) { + throw new ErrorResponse('[splatnet2] ' + data.message, response, data); + } + + return data; + } + + async getRecords() { + return this.fetch('/records'); + } + + async getStages() { + return this.fetch('/data/stages'); + } + + async getActiveFestivals() { + return this.fetch('/festivals/active'); + } + + async getTimeline() { + return this.fetch('/timeline'); + } + + async getUserNicknameAndIcon(ids: string[]) { + return this.fetch('/nickname_and_icon?' + + ids.map(id => 'id=' + encodeURIComponent(id)).join('&')); + } + + async getSchedules() { + return this.fetch('/schedules'); + } + + async getHeroRecords() { + return this.fetch('/records/hero'); + } + + async getXPowerRankingSummary(season: string | Date) { + if (season instanceof Date) { + season = toSeasonId(season.getUTCFullYear(), season.getUTCMonth() + 1); + } + + let match = season.match(/^(\d+)-(\d{2})$/); + if (match) { + season = toSeasonId(parseInt(match[1]), parseInt(match[2])); + } + + if (!season.match(XPOWERRANKING_SEASON)) { + throw new Error('Invalid season ID'); + } + + return this.fetch('/x_power_ranking/' + season + '/summary'); + } + + async getXPowerRankingLeaderboard(season: string | Date, rule: XPowerRankingRule, page: number = 1) { + if (season instanceof Date) { + season = toSeasonId(season.getUTCFullYear(), season.getUTCMonth() + 1); + } + + let match = season.match(/^(\d+)-(\d{2})$/); + if (match) { + season = toSeasonId(parseInt(match[1]), parseInt(match[2])); + } + + if (!season.match(XPOWERRANKING_SEASON)) { + throw new Error('Invalid season ID'); + } + + return this.fetch('/x_power_ranking/' + season + '/' + rule + '?page=' + page); + } + + async getPastFestivals() { + return this.fetch('/festivals/pasts'); + } + + async getLeagueMatchRanking(id: string, region: LeagueRegion) { + if (!id.match(LEAGUE_ID)) { + throw new Error('Invalid league ID'); + } + + return this.fetch('/league_match_ranking/' + id + '/' + region); + } + + async getResults() { + return this.fetch('/results'); + } + + async getResult(id: string | number) { + return this.fetch('/results/' + id); + } + + async getCoopResults() { + return this.fetch('/coop_results'); + } + + async getCoopResult(id: number) { + return this.fetch('/coop_results/' + id); + } + + async getCoopSchedules() { + return this.fetch('/coop_schedules'); + } + + async getShopMerchandises() { + return this.fetch('/onlineshop/merchandises'); + } + + async shareProfile(stage: string, colour: ShareColour) { + const boundary = uuidgen(); + + const data = `--${boundary} +Content-Disposition: form-data; name="stage" + +${stage} +--${boundary} +Content-Disposition: form-data; name="color" + +${colour} +--${boundary}-- +`.replace(/\r?\n/g, '\r\n'); + + return this.fetch('/share/profile', 'POST', data, { + 'Content-Type': 'multipart/form-data; boundary=' + boundary, + 'Referer': 'https://app.splatoon2.nintendo.net/home', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://app.splatoon2.nintendo.net', + }); + } + + async shareResultsSummary() { + return this.fetch('/share/results/summary', 'POST', '', { + 'Referer': 'https://app.splatoon2.nintendo.net/results', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://app.splatoon2.nintendo.net', + }); + } + + async shareResult(id: string | number) { + return this.fetch('/share/results/' + id, 'POST', '', { + 'Referer': 'https://app.splatoon2.nintendo.net/results/' + id, + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://app.splatoon2.nintendo.net', + }); + } + + static async createWithZnc(nso: ZncApi, user: NintendoAccountUser) { + const data = await this.loginWithZnc(nso, user); + + return { + splatnet: new this(data.iksm_session, data.useragent), + data, + }; + } + + static async loginWithZnc(nso: ZncApi, user: NintendoAccountUser) { + const webserviceToken = await nso.getWebServiceToken(SPLATNET2_WEBSERVICE_ID); + + return this.loginWithWebServiceToken(webserviceToken.result, user); + } + + static async loginWithWebServiceToken(webserviceToken: WebServiceToken, user: NintendoAccountUser): Promise { + const url = new URL(SPLATNET2_WEBSERVICE_URL); + url.search = new URLSearchParams({ + lang: user.language, + na_country: user.country, + na_lang: user.language, + }).toString(); + + const response = await fetch(url.toString(), { + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': SPLATNET2_WEBSERVICE_USERAGENT, + 'x-gamewebtoken': webserviceToken.accessToken, + 'dnt': '1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-GB,en-US;q=0.8', + 'X-Requested-With': 'com.nintendo.znca', + }, + }); + + debug('fetch %s %s, response %s', 'GET', url, response.status); + + if (response.status !== 200) { + throw new ErrorResponse('Unknown error', response); + } + + const cookies = response.headers.get('Set-Cookie'); + const match = cookies?.match(/\biksm_session=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i); + + if (!match) { + throw new ErrorResponse('Response didn\'t include iksm_session cookie', response); + } + + const iksm_session = decodeURIComponent(match[1]); + // Nintendo sets the expires field to an invalid timestamp - browsers don't care but Data.parse does + const expires = decodeURIComponent(match[8] || '') + .replace(/(\b)(\d{1,2})-([a-z]{3})-(\d{4})(\b)/gi, '$1$2 $3 $4$5'); + + debug('iksm_session %s, expires %s', iksm_session, expires); + + const expires_at = expires ? Date.parse(expires) : Date.now() + 24 * 60 * 60 * 1000; + + return { + webserviceToken, + url: url.toString(), + cookies: cookies!, + + iksm_session, + expires_at, + useragent: SPLATNET2_WEBSERVICE_USERAGENT, + }; + } +} + +export function toSeasonId(year: number, month: number) { + const nextyear = month === 12 ? year + 1 : year; + const nextmonth = month === 12 ? 1 : month + 1; + + if (year < 2000) throw new Error('Invalid season ID'); + if (nextyear >= 2100) throw new Error('Invalid season ID'); + + return ('' + (year - 2000)).padStart(2, '0') + + ('' + month).padStart(2, '0') + + '01T00_' + + ('' + (nextyear - 2000)).padStart(2, '0') + + ('' + nextmonth).padStart(2, '0') + + '01T00'; +} + +export enum XPowerRankingRule { + SPLAT_ZONES = 'splat_zones', + TOWER_CONTROL = 'tower_control', + RAINMAKER = 'rainmaker', + CLAM_BLITZ = 'clam_blitz', +} + +export function toLeagueId(date: Date, type: LeagueType) { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth() + 1; + const day = date.getUTCDate(); + const hour = Math.floor(date.getUTCHours() / 2) * 2; + + if (year < 2000) throw new Error('Invalid league ID'); + if (year >= 2100) throw new Error('Invalid league ID'); + + return ('' + (year - 2000)).padStart(2, '0') + + ('' + month).padStart(2, '0') + + ('' + day).padStart(2, '0') + + ('' + hour).padStart(2, '0') + + type; +} + +export enum LeagueType { + TEAM = 'T', + PAIR = 'P', +} + +export enum LeagueRegion { + ALL_REGIONS = 'ALL', + JAPAN = 'JP', + NA_AU_NZ = 'US', + EUROPE = 'EU', +} + +export enum ShareColour { + PINK = 'pink', + GREEN = 'green', + YELLOW = 'yellow', + PURPLE = 'purple', + BLUE = 'blue', + SUN_YELLOW = 'sun-yellow', +} diff --git a/src/api/znc-types.ts b/src/api/znc-types.ts index bc083c3..2d70f91 100644 --- a/src/api/znc-types.ts +++ b/src/api/znc-types.ts @@ -73,6 +73,7 @@ export interface Game { imageUri: string; shopUri: string; totalPlayTime: number; + /** 0 if never played before */ firstPlayedAt: number; sysDescription: string; } diff --git a/src/api/znc.ts b/src/api/znc.ts index 6bc28d1..1d38df2 100644 --- a/src/api/znc.ts +++ b/src/api/znc.ts @@ -72,17 +72,17 @@ export default class ZncApi { return this.fetch('/v3/User/ShowSelf', 'POST', '{"parameter":{}}'); } - async getWebServiceToken(id: string, nintendoAccountToken: string) { + async getWebServiceToken(id: string) { const uuid = uuidgen(); const timestamp = '' + Math.floor(Date.now() / 1000); const data = process.env.ZNCA_API_URL ? - await genfc(process.env.ZNCA_API_URL + '/f', nintendoAccountToken, timestamp, uuid, FlapgIid.APP) : - await flapg(nintendoAccountToken, timestamp, uuid, FlapgIid.APP); + await genfc(process.env.ZNCA_API_URL + '/f', this.token, timestamp, uuid, FlapgIid.APP) : + await flapg(this.token, timestamp, uuid, FlapgIid.APP); const req = { id, - registrationToken: nintendoAccountToken, + registrationToken: this.token, f: data.f, requestId: uuid, timestamp, diff --git a/src/cli/index.ts b/src/cli/index.ts index 486585a..44fb851 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,5 @@ export * as users from './users.js'; export * as nso from './nso.js'; +export * as splatnet2 from './splatnet2.js'; export * as pctl from './pctl.js'; export * as androidZncaApiServerFrida from './android-znca-api-server-frida.js'; diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 3edc712..1593b64 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -231,7 +231,7 @@ export async function handler(argv: ArgumentsCamelCase) { app.get('/api/znc/webservice/:id/token', nsoAuth, async (req, res) => { try { - const response = await req.znc!.getWebServiceToken(req.params.id, req.zncAuth!.credential.accessToken); + const response = await req.znc!.getWebServiceToken(req.params.id); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ diff --git a/src/cli/nso/presence.ts b/src/cli/nso/presence.ts index 7fbb9ea..a51a75b 100644 --- a/src/cli/nso/presence.ts +++ b/src/cli/nso/presence.ts @@ -175,88 +175,6 @@ class ZncDiscordPresence extends ZncNotifications { (online && 'name' in presence.game) || (this.argv.showConsoleOnline && presence?.state === PresenceState.INACTIVE); - if (show_presence) { - const fc = this.argv.friendCode === '' || this.argv.friendCode === '-' ? friendcode : this.forceFriendCode; - const discordpresence = 'name' in presence.game ? - getDiscordPresence(presence.game, fc) : - getInactiveDiscordPresence(fc); - - if (this.rpc && this.rpc.id !== discordpresence.id) { - const client = this.rpc.client; - this.rpc = null; - await client.destroy(); - } - - if (!this.rpc) { - const client = new DiscordRPC.Client({transport: 'ipc'}); - let attempts = 0; - let connected = false; - - while (attempts < 10) { - if (attempts === 0) debugDiscord('RPC connecting'); - else debugDiscord('RPC connecting, attempt %d', attempts + 1); - - try { - await client.connect(discordpresence.id); - debugDiscord('RPC connected'); - connected = true; - break; - } catch (err) {} - - attempts++; - await new Promise(rs => setTimeout(rs, 5000)); - } - - if (!connected) throw new Error('Failed to connect to Discord'); - - // @ts-expect-error - client.transport.on('close', async () => { - if (this.rpc?.client !== client) return; - - console.warn('[discordrpc] RPC client disconnected, attempting to reconnect'); - debugDiscord('RPC client disconnected'); - let attempts = 0; - let connected = false; - - while (attempts < 10) { - if (this.rpc?.client !== client) return; - - debugDiscord('RPC reconnecting, attempt %d', attempts + 1); - try { - await client.connect(discordpresence.id); - console.warn('[discordrpc] RPC reconnected'); - debugDiscord('RPC reconnected'); - connected = true; - break; - } catch (err) {} - - attempts++; - await new Promise(rs => setTimeout(rs, 5000)); - } - - if (!connected) throw new Error('Failed to reconnect to Discord'); - - throw new Error('Discord disconnected'); - }); - - this.rpc = {client, id: discordpresence.id}; - } - - if (discordpresence.title) { - if (discordpresence.title !== this.title?.id) { - this.title = {id: discordpresence.title, since: Date.now()}; - } - - if (discordpresence.showTimestamp) { - discordpresence.activity.startTimestamp = this.title.since; - } - } else { - this.title = null; - } - - this.rpc.client.setActivity(discordpresence.activity); - } - if (!presence || !show_presence) { if (this.rpc) { const client = this.rpc.client; @@ -265,7 +183,86 @@ class ZncDiscordPresence extends ZncNotifications { } this.title = null; + return; } + + const fc = this.argv.friendCode === '' || this.argv.friendCode === '-' ? friendcode : this.forceFriendCode; + const discordpresence = 'name' in presence.game ? + getDiscordPresence(presence.game, fc) : + getInactiveDiscordPresence(fc); + + if (this.rpc && this.rpc.id !== discordpresence.id) { + const client = this.rpc.client; + this.rpc = null; + await client.destroy(); + } + + if (!this.rpc) { + const client = new DiscordRPC.Client({transport: 'ipc'}); + let attempts = 0; + let connected = false; + + while (attempts < 10) { + if (attempts === 0) debugDiscord('RPC connecting'); + else debugDiscord('RPC connecting, attempt %d', attempts + 1); + + try { + await client.connect(discordpresence.id); + debugDiscord('RPC connected'); + connected = true; + break; + } catch (err) {} + + attempts++; + await new Promise(rs => setTimeout(rs, 5000)); + } + + if (!connected) throw new Error('Failed to connect to Discord'); + + // @ts-expect-error + client.transport.on('close', async () => { + if (this.rpc?.client !== client) return; + + debugDiscord('RPC client disconnected, attempting to reconnect'); + let attempts = 0; + let connected = false; + + while (attempts < 10) { + if (this.rpc?.client !== client) return; + + debugDiscord('RPC reconnecting, attempt %d', attempts + 1); + try { + await client.connect(discordpresence.id); + debugDiscord('RPC reconnected'); + connected = true; + break; + } catch (err) {} + + attempts++; + await new Promise(rs => setTimeout(rs, 5000)); + } + + if (!connected) throw new Error('Failed to reconnect to Discord'); + + throw new Error('Discord disconnected'); + }); + + this.rpc = {client, id: discordpresence.id}; + } + + if (discordpresence.title) { + if (discordpresence.title !== this.title?.id) { + this.title = {id: discordpresence.title, since: Date.now()}; + } + + if (discordpresence.showTimestamp) { + discordpresence.activity.startTimestamp = this.title.since; + } + } else { + this.title = null; + } + + this.rpc.client.setActivity(discordpresence.activity); } async update() { diff --git a/src/cli/nso/webservicetoken.ts b/src/cli/nso/webservicetoken.ts index 13a5d68..8f82b84 100644 --- a/src/cli/nso/webservicetoken.ts +++ b/src/cli/nso/webservicetoken.ts @@ -48,7 +48,7 @@ export async function handler(argv: ArgumentsCamelCase) { throw new Error('Invalid web service'); } - const webserviceToken = await nso.getWebServiceToken(argv.id, data.credential.accessToken); + const webserviceToken = await nso.getWebServiceToken(argv.id); // https://app.splatoon2.nintendo.net/?lang=en-GB&na_country=GB&na_lang=en-GB const url = new URL(webservice.uri); diff --git a/src/cli/pctl/dump-summaries.ts b/src/cli/pctl/dump-summaries.ts index 44bed12..d6796b5 100644 --- a/src/cli/pctl/dump-summaries.ts +++ b/src/cli/pctl/dump-summaries.ts @@ -43,11 +43,19 @@ export async function handler(argv: ArgumentsCamelCase) { const devices = await moon.getDevices(); - for (const device of argv.device ?? devices.items.map(d => d.deviceId)) { - console.warn('Downloading summaries for device %s', device); + for (const id of argv.device ?? []) { + if (!devices.items.find(d => d.deviceId === id)) { + console.warn('Device %s does not exist or is not linked to the authenticated user'); + } + } - await dumpMonthlySummariesForDevice(moon, argv.directory, '' + device); - await dumpDailySummariesForDevice(moon, argv.directory, '' + device); + for (const device of devices.items) { + if (argv.device && !argv.device.includes(device.deviceId)) continue; + + console.warn('Downloading summaries for device %s (%s)', device.label, device.deviceId); + + await dumpMonthlySummariesForDevice(moon, argv.directory, device.deviceId); + await dumpDailySummariesForDevice(moon, argv.directory, device.deviceId); } } diff --git a/src/cli/splatnet2.ts b/src/cli/splatnet2.ts new file mode 100644 index 0000000..132499b --- /dev/null +++ b/src/cli/splatnet2.ts @@ -0,0 +1,27 @@ +import createDebug from 'debug'; +import type { Arguments as ParentArguments } from '../cli.js'; +import { Argv, YargsArguments } from '../util.js'; +import * as commands from './splatnet2/index.js'; + +const debug = createDebug('cli:splatnet2'); + +export const command = 'splatnet2 '; +export const desc = 'SplatNet 2'; + +export function builder(yargs: Argv) { + for (const command of Object.values(commands)) { + // @ts-expect-error + yargs.command(command); + } + + return yargs.option('znc-proxy-url', { + describe: 'URL of Nintendo Switch Online app API proxy server to use', + type: 'string', + }).option('auto-update-iksm-session', { + describe: 'Automatically obtain and refresh the iksm_session cookie', + type: 'boolean', + default: false, + }); +} + +export type Arguments = YargsArguments>; diff --git a/src/cli/splatnet2/dump-records.ts b/src/cli/splatnet2/dump-records.ts new file mode 100644 index 0000000..0053fc8 --- /dev/null +++ b/src/cli/splatnet2/dump-records.ts @@ -0,0 +1,143 @@ +import createDebug from 'debug'; +import mkdirp from 'mkdirp'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; +import SplatNet2Api, { ShareColour } from '../../api/splatnet2.js'; +import { NicknameAndIcon } from '../../api/splatnet2-types.js'; +import fetch from 'node-fetch'; + +const debug = createDebug('cli:splatnet2:dump-records'); + +export const command = 'dump-records '; +export const desc = 'Download all other records'; + +export function builder(yargs: Argv) { + return yargs.positional('directory', { + describe: 'Directory to write record data to', + type: 'string', + demandOption: true, + }).option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).option('user-records', { + describe: 'Include user records', + type: 'boolean', + default: true, + }).option('profile-image', { + describe: 'Include profile image', + type: 'boolean', + default: false, + }).option('favourite-stage', { + describe: 'Favourite stage to include on profile image', + type: 'string', + }).option('favourite-colour', { + describe: 'Favourite colour to include on profile image', + type: 'string', + }).option('hero-records', { + describe: 'Include hero (Octo Canyon) records', + type: 'boolean', + default: true, + }).option('timeline', { + describe: 'Include timeline records', + type: 'boolean', + default: true, + }); +} + +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 getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession); + + await mkdirp(argv.directory); + + const [records, stages, activefestivals, timeline] = await Promise.all([ + splatnet.getRecords(), + splatnet.getStages(), + splatnet.getActiveFestivals(), + splatnet.getTimeline(), + ]); + const nickname_and_icons = await splatnet.getUserNicknameAndIcon([records.records.player.principal_id]); + + if (argv.userRecords) { + const filename = 'splatnet2-records-' + records.records.unique_id + '-' + Date.now() + '.json'; + const file = path.join(argv.directory, filename); + + debug('Writing records %s', filename); + await fs.writeFile(file, JSON.stringify(records, null, 4) + '\n', 'utf-8'); + + const ni_filename = 'splatnet2-ni-' + records.records.unique_id + '-' + Date.now() + '.json'; + const ni_file = path.join(argv.directory, ni_filename); + + debug('Writing records %s', ni_filename); + await fs.writeFile(ni_file, JSON.stringify(nickname_and_icons.nickname_and_icons[0], null, 4) + '\n', 'utf-8'); + } + + if (argv.profileImage) { + const filename = 'splatnet2-profile-' + records.records.unique_id + '-' + Date.now() + '.json'; + const file = path.join(argv.directory, filename); + const image_filename = 'splatnet2-profile-' + records.records.unique_id + '-' + Date.now() + '.png'; + const image_file = path.join(argv.directory, image_filename); + + const stage = argv.favouriteStage ? + stages.stages.find(s => s.id === argv.favouriteStage || + s.name.toLowerCase() === argv.favouriteStage?.toLowerCase()) : + stages.stages[0]; + + if (!stage) { + debug('Invalid favourite stage "%s"', argv.favouriteStage); + } + + // @ts-expect-error + if (!Object.values(ShareColour).includes(argv.favouriteColour)) { + argv.favouriteColour = ShareColour.PINK; + } + + debug('Fetching profile image URL'); + const share = await splatnet.shareProfile(stage?.id ?? stages.stages[0].id, argv.favouriteColour as ShareColour); + + debug('Fetching profile image'); + const image_response = await fetch(share.url); + const image = await image_response.buffer(); + + debug('Writing profile image data %s', filename); + await fs.writeFile(file, JSON.stringify({ + share, + stage: stage ?? stages.stages[0], + colour: argv.favouriteColour, + }, null, 4) + '\n', 'utf-8'); + + debug('Writing profile image %s', filename); + await fs.writeFile(image_file, image); + } + + if (argv.heroRecords) { + debug('Fetching hero records'); + const hero = await splatnet.getHeroRecords(); + + const filename = 'splatnet2-hero-' + records.records.unique_id + '-' + Date.now() + '.json'; + const file = path.join(argv.directory, filename); + + debug('Writing hero records %s', filename); + await fs.writeFile(file, JSON.stringify(hero, null, 4) + '\n', 'utf-8'); + } + + if (argv.timeline) { + const filename = 'splatnet2-timeline-' + records.records.unique_id + '-' + Date.now() + '.json'; + const file = path.join(argv.directory, filename); + + debug('Writing timeline %s', filename); + await fs.writeFile(file, JSON.stringify(records, null, 4) + '\n', 'utf-8'); + } +} diff --git a/src/cli/splatnet2/dump-results.ts b/src/cli/splatnet2/dump-results.ts new file mode 100644 index 0000000..7ef73e2 --- /dev/null +++ b/src/cli/splatnet2/dump-results.ts @@ -0,0 +1,194 @@ +import createDebug from 'debug'; +import mkdirp from 'mkdirp'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; +import SplatNet2Api from '../../api/splatnet2.js'; +import { NicknameAndIcon } from '../../api/splatnet2-types.js'; +import fetch from 'node-fetch'; + +const debug = createDebug('cli:splatnet2:dump-results'); + +export const command = 'dump-results '; +export const desc = 'Download all battle and coop results'; + +export function builder(yargs: Argv) { + return yargs.positional('directory', { + describe: 'Directory to write record data to', + type: 'string', + demandOption: true, + }).option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).option('battles', { + describe: 'Include regular/ranked/private/festival battle results', + type: 'boolean', + default: true, + }).option('battle-summary-image', { + describe: 'Include regular/ranked/private/festival battle summary image', + type: 'boolean', + default: false, + }).option('battle-images', { + describe: 'Include regular/ranked/private/festival battle result images', + type: 'boolean', + default: false, + }).option('coop', { + describe: 'Include coop (Salmon Run) results', + type: 'boolean', + default: true, + }); +} + +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 getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession); + + await mkdirp(argv.directory); + + const records = argv.coop ? await splatnet.getRecords() : null; + + if (argv.battles) { + await dumpResults(splatnet, argv.directory, argv.battleImages, argv.battleSummaryImage); + } + if (argv.coop) { + await dumpCoopResults(splatnet, argv.directory, records!.records.unique_id); + } +} + +async function dumpResults(splatnet: SplatNet2Api, directory: string, images = false, summary_image = images) { + debug('Fetching battle results'); + const results = await splatnet.getResults(); + + const summary_filename = 'splatnet2-results-summary-' + results.unique_id + '-' + Date.now() + '.json'; + const summary_file = path.join(directory, summary_filename); + + debug('Writing summary %s', summary_filename); + await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8'); + + if (summary_image) { + const filename = 'splatnet2-results-summary-image-' + results.unique_id + '-' + Date.now() + '.json'; + const file = path.join(directory, filename); + const image_filename = 'splatnet2-results-summary-image-' + results.unique_id + '-' + Date.now() + '.png'; + const image_file = path.join(directory, image_filename); + + debug('Fetching battle results summary image URL'); + const share = await splatnet.shareResultsSummary(); + + debug('Fetching battle results summary image'); + const image_response = await fetch(share.url); + const image = await image_response.buffer(); + + debug('Writing battle results summary image data %s', filename); + await fs.writeFile(file, JSON.stringify({ + share, + }, null, 4) + '\n', 'utf-8'); + + debug('Writing battle results summary image %s', filename); + await fs.writeFile(image_file, image); + } + + for (const item of results.results) { + const filename = 'splatnet2-result-' + results.unique_id + '-' + item.battle_number + '-' + item.type + '.json'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + debug('Skipping battle result %d, file already exists', item.battle_number); + } catch (err) { + debug('Fetching battle result %d', item.battle_number); + const result = await splatnet.getResult(item.battle_number); + + const nickname_and_icons: NicknameAndIcon[] = []; + + for (const playerresult of [result.player_result, ...result.my_team_members, ...result.other_team_members]) { + const nickname_and_icon = await splatnet.getUserNicknameAndIcon([playerresult.player.principal_id]); + nickname_and_icons.push(...nickname_and_icon.nickname_and_icons); + } + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result, + nickname_and_icons, + }, null, 4) + '\n', 'utf-8'); + } + + if (images) { + const filename = 'splatnet2-result-image-' + results.unique_id + '-' + + item.battle_number + '-' + item.type + '.json'; + const file = path.join(directory, filename); + const image_filename = 'splatnet2-result-image-' + results.unique_id + '-' + + item.battle_number + '-' + item.type + '.png'; + const image_file = path.join(directory, image_filename); + + try { + await fs.stat(file); + await fs.stat(image_file); + debug('Skipping battle result image %d, file already exists', item.battle_number); + } catch (err) { + debug('Fetching battle results summary image URL'); + const share = await splatnet.shareResult(item.battle_number); + + debug('Fetching battle results summary image'); + const image_response = await fetch(share.url); + const image = await image_response.buffer(); + + debug('Writing battle results summary image data %s', filename); + await fs.writeFile(file, JSON.stringify({ + share, + }, null, 4) + '\n', 'utf-8'); + + debug('Writing battle results summary image %s', filename); + await fs.writeFile(image_file, image); + } + } + } +} + +async function dumpCoopResults(splatnet: SplatNet2Api, directory: string, user_id: string) { + debug('Fetching coop results'); + const results = await splatnet.getCoopResults(); + + const summary_filename = 'splatnet2-coop-summary-' + user_id + '-' + Date.now() + '.json'; + const summary_file = path.join(directory, summary_filename); + + debug('Writing summary %s', summary_filename); + await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8'); + + for (const item of results.results) { + const filename = 'splatnet2-coop-result-' + user_id + '-' + item.job_id + '.json'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + debug('Skipping coop result %d, file already exists', item.job_id); + continue; + } catch (err) {} + + debug('Fetching coop result %d', item.job_id); + const result = await splatnet.getCoopResult(item.job_id); + + const nickname_and_icons: NicknameAndIcon[] = []; + + for (const playerresult of [result.my_result, ...result.other_results]) { + const nickname_and_icon = await splatnet.getUserNicknameAndIcon([playerresult.pid]); + nickname_and_icons.push(...nickname_and_icon.nickname_and_icons); + } + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result, + nickname_and_icons, + }, null, 4) + '\n', 'utf-8'); + } +} diff --git a/src/cli/splatnet2/index.ts b/src/cli/splatnet2/index.ts new file mode 100644 index 0000000..82ef2d7 --- /dev/null +++ b/src/cli/splatnet2/index.ts @@ -0,0 +1,3 @@ +export * as user from './user.js'; +export * as dumpResults from './dump-results.js'; +export * as dumpRecords from './dump-records.js'; diff --git a/src/cli/splatnet2/user.ts b/src/cli/splatnet2/user.ts new file mode 100644 index 0000000..4aa9aff --- /dev/null +++ b/src/cli/splatnet2/user.ts @@ -0,0 +1,45 @@ +import createDebug from 'debug'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; + +const debug = createDebug('cli:splatnet2:user'); + +export const command = 'user'; +export const desc = 'Get the authenticated Nintendo Account\'s player record'; + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }); +} + +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 getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession); + + const [records, stages, activefestivals, timeline] = await Promise.all([ + splatnet.getRecords(), + splatnet.getStages(), + splatnet.getActiveFestivals(), + splatnet.getTimeline(), + ]); + const nickname_and_icons = await splatnet.getUserNicknameAndIcon([records.records.player.principal_id]); + + console.log('Player %s (Splatoon 2 ID %s, NSA ID %s) level %d', + records.records.player.nickname, + records.records.unique_id, + records.records.player.principal_id, + records.records.player.player_rank, + records.records.player.player_type); +} diff --git a/src/cli/splatnet2/util.ts b/src/cli/splatnet2/util.ts new file mode 100644 index 0000000..2864cb1 --- /dev/null +++ b/src/cli/splatnet2/util.ts @@ -0,0 +1,42 @@ +import createDebug from 'debug'; +import persist from 'node-persist'; +import { getToken } from '../../util.js'; +import SplatNet2Api, { SavedIksmSessionToken } from '../../api/splatnet2.js'; + +const debug = createDebug('cli:splatnet2'); + +export async function getIksmToken(storage: persist.LocalStorage, token: string, proxy_url?: string, allow_fetch_token = false) { + if (!token) { + console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi nso token`.'); + throw new Error('Invalid token'); + } + + const existingToken: SavedIksmSessionToken | undefined = await storage.getItem('IksmToken.' + token); + + if (!existingToken || existingToken.expires_at <= Date.now()) { + if (!allow_fetch_token) { + throw new Error('No valid iksm_session cookie'); + } + + console.warn('Authenticating to SplatNet 2'); + debug('Authenticating to SplatNet 2'); + + const {nso, data} = await getToken(storage, token, proxy_url); + + const existingToken = await SplatNet2Api.loginWithZnc(nso, data.user); + + await storage.setItem('IksmToken.' + token, existingToken); + + return { + splatnet: new SplatNet2Api(existingToken.iksm_session, existingToken.useragent), + data: existingToken, + }; + } + + debug('Using existing token'); + + return { + splatnet: new SplatNet2Api(existingToken.iksm_session, existingToken.useragent), + data: existingToken, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..58014f1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,19 @@ +export { default as ZncApi } from './api/znc.js'; +export { default as ZncProxyApi } from './api/znc-proxy.js'; +export * as znc from './api/znc-types.js'; +export { default as MoonApi } from './api/moon.js'; +export * as moon from './api/moon-types.js'; +export * as na from './api/na.js'; +export * as f from './api/f.js'; + +export { + default as SplatNet2Api, + XPowerRankingRule as SplatNet2XPowerRankingRule, + LeagueType as SplatNet2LeagueType, + LeagueRegion as SplatNet2LeagueRegion, + ShareColour as SplatNet2ProfileColour, +} from './api/splatnet2.js'; +export * as splatnet2 from './api/splatnet2-types.js'; + +export { getTitleIdFromEcUrl } from './util.js'; +export { ErrorResponse } from './api/util.js'; diff --git a/src/util.ts b/src/util.ts index 18ef1c9..198b6da 100644 --- a/src/util.ts +++ b/src/util.ts @@ -140,7 +140,7 @@ export function getDiscordPresence(game: Game, friendcode?: CurrentUser['links'] if (game.totalPlayTime >= 60) { text.push('Played for ' + hrduration(game.totalPlayTime) + - ' since ' + new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB')); + ' since ' + (game.firstPlayedAt ? new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB') : 'now')); } return {