From 3919ceeb2136f3db6457f5c79f08a2806131344e Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Wed, 16 Mar 2022 20:58:15 +0000 Subject: [PATCH] Add parental controls --- .vscode/settings.json | 2 +- package-lock.json | 36 ++++ package.json | 2 + src/api/moon-types.ts | 276 ++++++++++++++++++++++++++++++ src/api/moon.ts | 108 ++++++++++++ src/api/na.ts | 6 +- src/api/znc.ts | 3 +- src/cli/auth.ts | 7 +- src/cli/friends.ts | 11 +- src/cli/index.ts | 1 + src/cli/pctl.ts | 16 ++ src/cli/pctl/auth.ts | 114 ++++++++++++ src/cli/pctl/daily-summaries.ts | 79 +++++++++ src/cli/pctl/devices.ts | 59 +++++++ src/cli/pctl/dump-summaries.ts | 95 ++++++++++ src/cli/pctl/index.ts | 8 + src/cli/pctl/monthly-summaries.ts | 51 ++++++ src/cli/pctl/monthly-summary.ts | 76 ++++++++ src/cli/pctl/settings.ts | 37 ++++ src/cli/pctl/token.ts | 56 ++++++ src/cli/pctl/user.ts | 31 ++++ src/cli/users.ts | 25 +-- src/util.ts | 70 +++++++- 23 files changed, 1137 insertions(+), 32 deletions(-) create mode 100644 src/api/moon-types.ts create mode 100644 src/api/moon.ts create mode 100644 src/cli/pctl.ts create mode 100644 src/cli/pctl/auth.ts create mode 100644 src/cli/pctl/daily-summaries.ts create mode 100644 src/cli/pctl/devices.ts create mode 100644 src/cli/pctl/dump-summaries.ts create mode 100644 src/cli/pctl/index.ts create mode 100644 src/cli/pctl/monthly-summaries.ts create mode 100644 src/cli/pctl/monthly-summary.ts create mode 100644 src/cli/pctl/settings.ts create mode 100644 src/cli/pctl/token.ts create mode 100644 src/cli/pctl/user.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index cbcb31d..e6544b7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "files.associations": { - "**/data/*": "json" + "**/data/persist/*": "json" }, "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/package-lock.json b/package-lock.json index 0c70637..4f340f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "discord-rpc": "^4.0.1", "env-paths": "^3.0.0", "express": "^4.17.3", + "mkdirp": "^1.0.4", "node-fetch": "^3.2.2", "node-notifier": "^10.0.1", "node-persist": "^3.1.0", @@ -28,6 +29,7 @@ "@types/debug": "^4.1.7", "@types/discord-rpc": "^4.0.0", "@types/express": "^4.17.13", + "@types/mkdirp": "^1.0.2", "@types/node": "^17.0.21", "@types/node-notifier": "^8.0.2", "@types/node-persist": "^3.1.2", @@ -142,6 +144,15 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, + "node_modules/@types/mkdirp": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-1.0.2.tgz", + "integrity": "sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -1514,6 +1525,17 @@ "node": "*" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -2419,6 +2441,15 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, + "@types/mkdirp": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-1.0.2.tgz", + "integrity": "sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -3485,6 +3516,11 @@ "brace-expansion": "^1.1.7" } }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 5dc3ca6..d988b40 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "discord-rpc": "^4.0.1", "env-paths": "^3.0.0", "express": "^4.17.3", + "mkdirp": "^1.0.4", "node-fetch": "^3.2.2", "node-notifier": "^10.0.1", "node-persist": "^3.1.0", @@ -38,6 +39,7 @@ "@types/debug": "^4.1.7", "@types/discord-rpc": "^4.0.0", "@types/express": "^4.17.13", + "@types/mkdirp": "^1.0.2", "@types/node": "^17.0.21", "@types/node-notifier": "^8.0.2", "@types/node-persist": "^3.1.2", diff --git a/src/api/moon-types.ts b/src/api/moon-types.ts new file mode 100644 index 0000000..60a7b97 --- /dev/null +++ b/src/api/moon-types.ts @@ -0,0 +1,276 @@ +export interface MoonError { + type: string; // "https://moon.nintendo.com/errors/v1/401/invalid_token" + status: number; // 401 + errorCode: string; // "invalid_token" + title: string; // "UnauthorizedException" + detail: string; // "" + instance: string; +} + +/** GET /v1/users/{nintendoAccountId} */ +export interface User { + nintendoAccountId: string; + nickname: string; + country: string; // "GB" + language: string; // "en-GB" + miiUri: { + extraLarge: string; + large: string; + medium: string; + small: string; + extraSmall: string; + }; + analyticsOptedIn: boolean; + acceptedNotification: { + all: boolean; + }; + notices: unknown[]; + createdAt: number; + updatedAt: number; +} + +/** GET /v1/users/{nintendoAccountId}/smart_devices */ +export interface SmartDevices { + count: number; + items: SmartDevice[]; +} + +export interface SmartDevice { + /** UUID v4, for iOS devices this is uppercase hex, for Android devices this is lowercase hex?? */ + id: string; + nintendoAccountId: string; + bundleId: string; + os: SmartDeviceOS; + osVersion: string; + modelName: string; + timeZone: string; + appVersion: { + displayedVersion: string; // "1.15.1", "1.16.0" + internalVersion: number; // 305, 247 + }; + osLanguage: string; + appLanguage: string; + notificationToken: string | null; + updateRequired: boolean; + createdAt: number; + updatedAt: number; +} +export enum SmartDeviceOS { + IOS = 'IOS', + ANDROID = 'ANDROID', +} + +/** GET /v1/users/{nintendoAccountId}/devices */ +export interface Devices { + count: number; + items: PairedDevice[]; +} + +export interface PairedDevice { + deviceId: string; + device: Device; + nintendoAccountId: string; + label: string; + parentalControlSettingState: { + deviceId: string; + targetEtag: string; + synchronizationStatus: 'SYNCHRONIZED'; + createdAt: number; + updatedAt: number; + }; + hasNewMonthlySummary: boolean; + hasFirstDailySummary: boolean; + createdAt: number; + updatedAt: number; +} + +export interface Device { + id: string; + notificationToken: string; + timeZone: string; + language: string; + region: string; // "EUROPE" + serialNumber: string; + firmwareVersion: { + displayedVersion: string; // "13.2.1" + internalVersion: number; // 852481 + }; + links: { + pairingCode: { + code: string; + createdAt: number; + expiresAt: number; + }; + }; + activated: boolean; + synchronizedUnlockCode: string; + synchronizedParentalControlSetting: { + synchronizedEtag: string; + synchronizedAt: number; + }; + lastOnlineCheckedAt: number; + alarmSetting: { + visibility: 'VISIBLE'; + invisibleUntil: number; + }; + createdAt: number; + updatedAt: number; +} + +/** GET /v1/devices/{deviceId}/daily_summaries */ +export interface DailySummaries { + count: number; + items: DailySummary[]; + updatedRecently: boolean; +} + +export interface DailySummary { + deviceId: string; + date: string; // "2022-03-14" + result: DailySummaryResult; + playingTime: number; + exceededTime: null; + disabledTime: number; + miscTime: number; + importantInfos: ImportantInfo[]; + notices: unknown[]; + observations: Observation[]; + playedApps: PlayedTitle[]; + anonymousPlayer: AnonymousPlayer | null; + devicePlayers: DevicePlayer[]; + timeZoneUtcOffsetSeconds: number; + lastPlayedAt: number | null; + createdAt: number; + updatedAt: number; +} +export enum DailySummaryResult { + CALCULATING = 'CALCULATING', + ACHIEVED = 'ACHIEVED', +} +export enum ImportantInfo { + DID_WRONG_UNLOCK_CODE = 'DID_WRONG_UNLOCK_CODE', +} + +export interface Title { + applicationId: string; + title: string; + imageUri: { + extraSmall: string; + small: string; + medium: string; + large: string; + extraLarge: string; + }; + hasUgc: boolean; + shopUri: string; + firstPlayDate: string | null; +} + +export interface ObservationTitleDownloaded { + type: 'DID_APP_DOWNLOAD_START'; + applications: ObservationTitleDownloadedTitle[]; +} +export interface ObservationTitleDownloadedTitle extends Title { + firstPlayDate: null; +} + +export type Observation = ObservationTitleDownloaded; + +export interface PlayedTitle extends Title { + firstPlayDate: string; +} + +export interface AnonymousPlayer { + playingTime: number; + playedApps: DevicePlayerTitle[]; +} + +export interface DevicePlayer extends AnonymousPlayer { + playerId: string; + nickname: string; + imageUri: string; +} + +export interface DevicePlayerTitle { + applicationId: string; + firstPlayDate: string; + playingTime: number; +} + +/** GET /v1/devices/{deviceId}/monthly_summaries */ +export interface MonthlySummaries { + count: number; + indexes: string[]; // ["2022-02", ...] + items: MonthlySummariesMonthlySummary[]; +} +export interface MonthlySummariesMonthlySummary { + deviceId: string; + month: string; // "2022-02" +} + +/** GET /v1/devices/{deviceId}/monthly_summaries/{month} */ +export interface MonthlySummary { + deviceId: string; + month: string; // "2022-02" + dailySummaries: Record; + playingDays: number; + playedApps: MonthlySummaryPlayedTitle[]; + insights: MonthlySummaryInsights; + devicePlayers: MonthlySummaryDevicePlayer[]; + includedMajorVersions: number[]; + createdAt: number; + updatedAt: number; +} +export interface MonthlySummaryDevicePlayer { + playerId: string; + nickname: string; + imageUri: string; + dailySummaries: Record; + insights: MonthlySummaryInsights; +} +export interface MonthlySummaryDailySummary { + date: string; + result: 'ACHIEVED'; + playingTime: number; +} +export interface MonthlySummaryPlayedTitle extends PlayedTitle { + playingDays: number; + position: MonthlySummaryTitleRankingPosition; +} +export interface MonthlySummaryInsights { + thisMonth: { + averagePlayingTime: number; + playingDays: number; + playingTime: number; + }; + previousMonth: { + averagePlayingTime: number; + playingDays: number; + playingTime: number; + } | null; + rankings: { + byDay: MonthlySummaryTitleRanking[]; + byTime: MonthlySummaryTitleRanking[]; + }; +} +export interface MonthlySummaryTitleRanking { + applicationId: string; + units: number; + position: MonthlySummaryTitleRankingPosition; + ratio: number; +} +export enum MonthlySummaryTitleRankingPosition { + UP = 'UP', + STAY = 'STAY', + DOWN = 'DOWN', + NEW = 'NEW', +} + +/** GET /v1/devices/{deviceId}/parental_control_setting_state */ +export interface ParentalControlSettingState { + deviceId: string; + targetEtag: string; + synchronizationStatus: 'SYNCHRONIZED'; + createdAt: number; + updatedAt: number; +} diff --git a/src/api/moon.ts b/src/api/moon.ts new file mode 100644 index 0000000..9dc4290 --- /dev/null +++ b/src/api/moon.ts @@ -0,0 +1,108 @@ +import fetch from 'node-fetch'; +import createDebug from 'debug'; +import { getNintendoAccountToken, getNintendoAccountUser } from './na.js'; +import { ErrorResponse } from './util.js'; +import { DailySummaries, Devices, MonthlySummaries, MonthlySummary, MoonError, SmartDevices, User } from './moon-types.js'; + +const debug = createDebug('api:moon'); + +const MOON_URL = 'https://api-lp1.pctl.srv.nintendo.net/moon'; +export const MOON_CLIENT_ID = '54789befb391a838'; + +export default class MoonApi { + constructor( + public token: string, + public naId: string + ) {} + + async fetch(url: string, method = 'GET', body?: string, headers?: object) { + const response = await fetch(MOON_URL + url, { + method: method, + headers: Object.assign({ + 'Authorization': 'Bearer ' + this.token, + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + 'X-Moon-App-Id': 'com.nintendo.znma', + 'X-Moon-Os': 'ANDROID', + 'X-Moon-Os-Version': '26', + 'X-Moon-Model': '', + 'X-Moon-TimeZone': 'Europe/London', + 'X-Moon-Os-Language': 'en-GB', + 'X-Moon-App-Language': 'en-GB', + 'X-Moon-App-Display-Version': '1.16.0', + 'X-Moon-App-Internal-Version': '247', + 'User-Agent': 'moon_ANDROID/1.16.0 (com.nintendo.znma; build:247; ANDROID 26)', + }, headers), + body: body, + }); + + debug('fetch %s %s, response %s', method, url, response.status); + + const data = await response.json() as T | MoonError; + + if ('errorCode' in data) { + throw new ErrorResponse('[moon] ' + data.title, response, data); + } + + return data; + } + + async getUser() { + return this.fetch('/v1/users/' + this.naId); + } + + async getSmartDevices() { + return this.fetch('/v1/users/' + this.naId + '/smart_devices'); + } + + async getDevices() { + return this.fetch('/v1/users/' + this.naId + '/devices'); + } + + async getDailySummaries(id: string) { + return this.fetch('/v1/devices/' + id + '/daily_summaries'); + } + + async getMonthlySummaries(id: string) { + return this.fetch('/v1/devices/' + id + '/monthly_summaries'); + } + + async getMonthlySummary(id: string, month: string) { + return this.fetch('/v1/devices/' + id + '/monthly_summaries/' + month); + } + + async getParentalControlSettingState(id: string) { + return this.fetch('/v1/devices/' + id + '/parental_control_setting_state'); + } + + static async createWithSessionToken(token: string) { + const data = await this.loginWithSessionToken(token); + + return { + moon: new this(data.nintendoAccountToken.access_token!, data.user.id), + data, + }; + } + + async renewToken(token: string) { + const data = await MoonApi.loginWithSessionToken(token); + + this.token = data.nintendoAccountToken.access_token!; + this.naId = data.user.id; + + return data; + } + + static async loginWithSessionToken(token: string) { + // Nintendo Account token + const nintendoAccountToken = await getNintendoAccountToken(token, MOON_CLIENT_ID); + + // Nintendo Account user data + const user = await getNintendoAccountUser(nintendoAccountToken); + + return { + nintendoAccountToken, + user, + }; + } +} diff --git a/src/api/na.ts b/src/api/na.ts index cceb5c1..9ec9a0d 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -33,7 +33,7 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str return token; } -export async function getNintendoAccountToken(token: string) { +export async function getNintendoAccountToken(token: string, client_id: string) { debug('Getting Nintendo Account token'); const response = await fetch('https://accounts.nintendo.com/connect/1.0.0/api/token', { @@ -44,7 +44,7 @@ export async function getNintendoAccountToken(token: string) { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 8.0.0)', }, body: JSON.stringify({ - client_id: '71b963c1b7b6d119', + client_id, session_token: token, grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer-session-token', }), @@ -91,7 +91,7 @@ export interface NintendoAccountSessionToken { } export interface NintendoAccountToken { - scope: ['openid', 'user', 'user.birthday', 'user.mii', 'user.screenName']; + scope: string[]; token_type: 'Bearer'; id_token: string; access_token?: string; diff --git a/src/api/znc.ts b/src/api/znc.ts index 02c85cb..7502e4c 100644 --- a/src/api/znc.ts +++ b/src/api/znc.ts @@ -14,6 +14,7 @@ const ZNCA_VERSION = '2.0.0'; const ZNCA_USER_AGENT = `com.nintendo.znca/${ZNCA_VERSION}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`; const ZNC_URL = 'https://api-lp1.znc.srv.nintendo.net'; +export const ZNCA_CLIENT_ID = '71b963c1b7b6d119'; export default class ZncApi { constructor( @@ -112,7 +113,7 @@ export default class ZncApi { const timestamp = '' + Math.floor(Date.now() / 1000); // Nintendo Account token - const nintendoAccountToken = await getNintendoAccountToken(token); + const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID); // Nintendo Account user data const user = await getNintendoAccountUser(nintendoAccountToken); diff --git a/src/cli/auth.ts b/src/cli/auth.ts index f19467d..c23e785 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -4,6 +4,7 @@ import * as crypto from 'crypto'; import type { Arguments as ParentArguments } from '../cli.js'; import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js'; import { getNintendoAccountSessionToken } from '../api/na.js'; +import { ZNCA_CLIENT_ID } from '../api/znc.js'; const debug = createDebug('cli:auth'); @@ -31,7 +32,7 @@ export async function handler(argv: ArgumentsCamelCase) { const params = { state, redirect_uri: 'npf71b963c1b7b6d119://auth', - client_id: '71b963c1b7b6d119', + client_id: ZNCA_CLIENT_ID, scope: 'openid user user.birthday user.mii user.screenName', response_type: 'session_token_code', session_token_code_challenge: challenge, @@ -71,8 +72,8 @@ export async function handler(argv: ArgumentsCamelCase) { const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1)); debug('Redirect URL parameters', [...authorisedparams.entries()]); - const token = await getNintendoAccountSessionToken( - authorisedparams.get('session_token_code')!, verifier, '71b963c1b7b6d119'); + const code = authorisedparams.get('session_token_code')!; + const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID); console.log('Session token', token); diff --git a/src/cli/friends.ts b/src/cli/friends.ts index d5a9fb6..f85cbf5 100644 --- a/src/cli/friends.ts +++ b/src/cli/friends.ts @@ -3,7 +3,7 @@ import createDebug from 'debug'; import Table from 'cli-table/lib/index.js'; import { PresenceState } from '../api/znc-types.js'; import type { Arguments as ParentArguments } from '../cli.js'; -import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js'; +import { ArgumentsCamelCase, Argv, getToken, hrduration, initStorage, YargsArguments } from '../util.js'; const debug = createDebug('cli:friends'); @@ -67,8 +67,6 @@ export async function handler(argv: ArgumentsCamelCase) { for (const friend of friends.result.friends) { const online = friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING; - const hours = 'name' in friend.presence.game ? Math.floor(friend.presence.game.totalPlayTime / 60) : 0; - const minutes = 'name' in friend.presence.game ? friend.presence.game.totalPlayTime - (hours * 60) : 0; table.push([ friend.id, @@ -76,10 +74,9 @@ export async function handler(argv: ArgumentsCamelCase) { friend.name, online ? 'name' in friend.presence.game ? - 'Playing ' + friend.presence.game.name + - '; played for ' + (hours || !minutes ? hours + ' hour' + (hours === 1 ? '' : 's') : '') + - (minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') + - ' since ' + new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') : + 'Playing ' + friend.presence.game.name + '; played for ' + + hrduration(friend.presence.game.totalPlayTime) + ' since ' + + new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') : 'Online' : friend.presence.logoutAt ? 'Last seen ' + new Date(friend.presence.logoutAt * 1000).toISOString() : diff --git a/src/cli/index.ts b/src/cli/index.ts index ddadff2..cd0bd45 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,3 +9,4 @@ export * as friends from './friends.js'; export * as presence from './presence.js'; export * as notify from './notify.js'; export * as httpServer from './http-server.js'; +export * as pctl from './pctl.js'; diff --git a/src/cli/pctl.ts b/src/cli/pctl.ts new file mode 100644 index 0000000..7faed10 --- /dev/null +++ b/src/cli/pctl.ts @@ -0,0 +1,16 @@ +import createDebug from 'debug'; +import type { Arguments as ParentArguments } from '../cli.js'; +import { Argv } from '../util.js'; +import * as commands from './pctl/index.js'; + +const debug = createDebug('cli:pctl'); + +export const command = 'pctl '; +export const desc = 'Nintendo Switch Parental Controls'; + +export function builder(yargs: Argv) { + for (const command of Object.values(commands)) { + // @ts-expect-error + yargs.command(command); + } +} diff --git a/src/cli/pctl/auth.ts b/src/cli/pctl/auth.ts new file mode 100644 index 0000000..14b6332 --- /dev/null +++ b/src/cli/pctl/auth.ts @@ -0,0 +1,114 @@ +import * as util from 'util'; +import createDebug from 'debug'; +import * as crypto from 'crypto'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js'; +import { getNintendoAccountSessionToken } from '../../api/na.js'; +import { MOON_CLIENT_ID } from '../../api/moon.js'; + +const debug = createDebug('cli:pctl:auth'); + +export const command = 'auth'; +export const desc = 'Generate a link to login to a Nintendo Account'; + +export function builder(yargs: Argv) { + return yargs.option('auth', { + describe: 'Authenticate immediately', + type: 'boolean', + default: true, + }).option('select', { + describe: 'Set as default user (default: true if only user)', + type: 'boolean', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + const state = crypto.randomBytes(36).toString('base64url'); + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url'); + + const params = { + state, + redirect_uri: 'npf54789befb391a838://auth', + client_id: MOON_CLIENT_ID, + scope: [ + 'openid', + 'user', + 'user.mii', + 'moonUser:administration', + 'moonDevice:create', + 'moonOwnedDevice:administration', + 'moonParentalControlSetting', + 'moonParentalControlSetting:update', + 'moonParentalControlSettingState', + 'moonPairingState', + 'moonSmartDevice:administration', + 'moonDailySummary', + 'moonMonthlySummary', + ].join(' '), + response_type: 'session_token_code', + session_token_code_challenge: challenge, + session_token_code_challenge_method: 'S256', + }; + + const authoriseurl = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' + + new URLSearchParams(params).toString(); + + debug('Authentication parameters', { + state, + verifier, + challenge, + }, params); + + console.log('1. Open this URL and login to your Nintendo Account:'); + console.log(''); + console.log(authoriseurl); + console.log(''); + + console.log('2. On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "npf54789befb391a838://auth".'); + console.log(''); + + const read = await import('read'); + // @ts-expect-error + const prompt = util.promisify(read.default as typeof read); + + const applink = await prompt({ + prompt: `Paste the link: `, + // silent: true, + output: process.stderr, + }); + + console.log(''); + + const authorisedurl = new URL(applink); + const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1)); + debug('Redirect URL parameters', [...authorisedparams.entries()]); + + const code = authorisedparams.get('session_token_code')!; + const token = await getNintendoAccountSessionToken(code, verifier, MOON_CLIENT_ID); + + console.log('Session token', token); + + if (argv.auth) { + const storage = await initStorage(argv.dataPath); + + const {moon, data} = await getPctlToken(storage, token.session_token); + + console.log('Authenticated as Nintendo Account %s (%s)', + data.user.nickname, data.user.id); + + await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token.session_token); + + const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); + users.add(data.user.id); + await storage.setItem('NintendoAccountIds', [...users]); + + if ('select' in argv ? argv.select : users.size === 1) { + await storage.setItem('SelectedUser', data.user.id); + + console.log('Set as default user'); + } + } +} diff --git a/src/cli/pctl/daily-summaries.ts b/src/cli/pctl/daily-summaries.ts new file mode 100644 index 0000000..f478e5b --- /dev/null +++ b/src/cli/pctl/daily-summaries.ts @@ -0,0 +1,79 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, hrduration, initStorage, YargsArguments } from '../../util.js'; + +const debug = createDebug('cli:pctl:daily-summaries'); + +export const command = 'daily-summaries '; +export const desc = 'Show daily summaries'; + +export function builder(yargs: Argv) { + return yargs.positional('device', { + describe: 'Nintendo Switch device ID', + type: 'string', + demandOption: true, + }).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-pctl.' + usernsid); + const {moon, data} = await getPctlToken(storage, token); + + const summaries = await moon.getDailySummaries(argv.device); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(summaries, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(summaries)); + return; + } + + const table = new Table({ + head: [ + 'Date', + 'Status', + 'Play time', + 'Misc. time', + 'Titles played', + 'Users played', + 'Notices', + ], + }); + + for (const summary of summaries.items) { + table.push([ + summary.date, + summary.result, + hrduration(summary.playingTime / 60, true), + hrduration(summary.miscTime / 60, true), + summary.playedApps.map(t => t.title).join('\n'), + summary.devicePlayers.map(p => p.nickname) + .concat(summary.anonymousPlayer ? ['Unknown user'] : []).join('\n'), + [...summary.importantInfos, ...summary.observations.map(o => o.type)].join('\n'), + ]); + } + + console.log(table.toString()); +} diff --git a/src/cli/pctl/devices.ts b/src/cli/pctl/devices.ts new file mode 100644 index 0000000..4602835 --- /dev/null +++ b/src/cli/pctl/devices.ts @@ -0,0 +1,59 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js'; + +const debug = createDebug('cli:pctl:devices'); + +export const command = 'devices'; +export const desc = 'List Nintendo Switch consoles'; + +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) { + console.warn('Listing devices'); + + const storage = await initStorage(argv.dataPath); + + const usernsid = argv.user ?? await storage.getItem('SelectedUser'); + const token: string = argv.token || + await storage.getItem('NintendoAccountToken-pctl.' + usernsid); + const {moon, data} = await getPctlToken(storage, token); + + const devices = await moon.getDevices(); + + const table = new Table({ + head: [ + 'ID', + 'Label', + 'Serial number', + 'Software version', + 'PIN', + 'Last synchronised', + ], + }); + + for (const device of devices.items) { + table.push([ + device.deviceId, + device.label, + device.device.serialNumber, + device.device.firmwareVersion.displayedVersion + ' (' + device.device.firmwareVersion.internalVersion + ')', + device.device.synchronizedUnlockCode, + new Date(device.device.synchronizedParentalControlSetting.synchronizedAt * 1000).toISOString(), + ]); + } + + console.log(table.toString()); +} diff --git a/src/cli/pctl/dump-summaries.ts b/src/cli/pctl/dump-summaries.ts new file mode 100644 index 0000000..44bed12 --- /dev/null +++ b/src/cli/pctl/dump-summaries.ts @@ -0,0 +1,95 @@ +import createDebug from 'debug'; +import * as fs from 'fs/promises'; +import mkdirp from 'mkdirp'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js'; +import * as path from 'path'; +import { DailySummaryResult } from '../../api/moon-types.js'; +import MoonApi from '../../api/moon.js'; + +const debug = createDebug('cli:pctl:dump-summaries'); + +export const command = 'dump-summaries '; +export const desc = 'Download all daily and monthly summaries'; + +export function builder(yargs: Argv) { + return yargs.positional('directory', { + describe: 'Directory to write summary data to', + type: 'string', + demandOption: true, + }).option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).option('device', { + describe: 'Nintendo Switch device ID', + type: 'array', + }); +} + +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-pctl.' + usernsid); + const {moon, data} = await getPctlToken(storage, token); + + await mkdirp(argv.directory); + + 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); + + await dumpMonthlySummariesForDevice(moon, argv.directory, '' + device); + await dumpDailySummariesForDevice(moon, argv.directory, '' + device); + } +} + +async function dumpMonthlySummariesForDevice(moon: MoonApi, directory: string, device: string) { + debug('Fetching monthly summaries for device %s', device); + const monthlySummaries = await moon.getMonthlySummaries(device); + + for (const item of monthlySummaries.items) { + const filename = 'pctl-monthly-' + item.deviceId + '-' + item.month + '.json'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + debug('Skipping monthly summary %s for device %s, file already exists', item.month, item.deviceId); + continue; + } catch (err) {} + + debug('Fetching monthly summary %s for device %s', item.month, item.deviceId); + const summary = await moon.getMonthlySummary(item.deviceId, item.month); + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify(summary, null, 4) + '\n', 'utf-8'); + } +} + +async function dumpDailySummariesForDevice(moon: MoonApi, directory: string, device: string) { + debug('Fetching daily summaries for device %s', device); + const summaries = await moon.getDailySummaries(device); + const timestamp = Date.now(); + + for (const summary of summaries.items) { + const filename = 'pctl-daily-' + summary.deviceId + '-' + summary.date + + (summary.result === DailySummaryResult.ACHIEVED ? '' : '-' + timestamp) + '.json'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + debug('Skipping daily summary %s for device %s, file already exists', summary.date, summary.deviceId); + continue; + } catch (err) {} + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify(summary, null, 4) + '\n', 'utf-8'); + } +} diff --git a/src/cli/pctl/index.ts b/src/cli/pctl/index.ts new file mode 100644 index 0000000..8a4d77d --- /dev/null +++ b/src/cli/pctl/index.ts @@ -0,0 +1,8 @@ +export * as token from './token.js'; +export * as auth from './auth.js'; +export * as devices from './devices.js'; +export * as dailySummaries from './daily-summaries.js'; +export * as monthlySummaries from './monthly-summaries.js'; +export * as monthlySummary from './monthly-summary.js'; +export * as settings from './settings.js'; +export * as dumpSummaries from './dump-summaries.js'; diff --git a/src/cli/pctl/monthly-summaries.ts b/src/cli/pctl/monthly-summaries.ts new file mode 100644 index 0000000..55f2dc0 --- /dev/null +++ b/src/cli/pctl/monthly-summaries.ts @@ -0,0 +1,51 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js'; + +const debug = createDebug('cli:pctl:monthly-summaries'); + +export const command = 'monthly-summaries '; +export const desc = 'List monthly summaries'; + +export function builder(yargs: Argv) { + return yargs.positional('device', { + describe: 'Nintendo Switch device ID', + type: 'string', + demandOption: true, + }).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-pctl.' + usernsid); + const {moon, data} = await getPctlToken(storage, token); + + const summaries = await moon.getMonthlySummaries(argv.device); + + const table = new Table({ + head: [ + 'Month', + ], + }); + + for (const summary of summaries.items) { + table.push([ + summary.month, + ]); + } + + console.log(table.toString()); +} diff --git a/src/cli/pctl/monthly-summary.ts b/src/cli/pctl/monthly-summary.ts new file mode 100644 index 0000000..21892bd --- /dev/null +++ b/src/cli/pctl/monthly-summary.ts @@ -0,0 +1,76 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js'; + +const debug = createDebug('cli:pctl:monthly-summary'); + +export const command = 'monthly-summary '; +export const desc = 'Show monthly summary data'; + +export function builder(yargs: Argv) { + return yargs.positional('device', { + describe: 'Nintendo Switch device ID', + type: 'string', + demandOption: true, + }).positional('month', { + describe: 'Report month', + type: 'string', + demandOption: true, + }).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-pctl.' + usernsid); + const {moon, data} = await getPctlToken(storage, token); + + const summary = await moon.getMonthlySummary(argv.device, argv.month); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(summary, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(summary)); + return; + } + + const titles = new Table({ + head: [ + 'Title', + 'First played', + 'Days', + 'Ranking', + ], + }); + + for (const title of summary.playedApps) { + titles.push([ + title.title, + title.firstPlayDate, + title.playingDays, + title.position, + ]); + } + + console.log(titles.toString()); +} diff --git a/src/cli/pctl/settings.ts b/src/cli/pctl/settings.ts new file mode 100644 index 0000000..3f79b83 --- /dev/null +++ b/src/cli/pctl/settings.ts @@ -0,0 +1,37 @@ +import createDebug from 'debug'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js'; + +const debug = createDebug('cli:pctl:settings'); + +export const command = 'settings '; +export const desc = 'Show parental control setting state'; + +export function builder(yargs: Argv) { + return yargs.positional('device', { + describe: 'Nintendo Switch device ID', + type: 'string', + demandOption: true, + }).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-pctl.' + usernsid); + const {moon, data} = await getPctlToken(storage, token); + + const d = await moon.getParentalControlSettingState(argv.device); + + console.log(d); +} diff --git a/src/cli/pctl/token.ts b/src/cli/pctl/token.ts new file mode 100644 index 0000000..118ae50 --- /dev/null +++ b/src/cli/pctl/token.ts @@ -0,0 +1,56 @@ +import * as util from 'util'; +import createDebug from 'debug'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js'; + +const debug = createDebug('cli:pctl:token'); + +export const command = 'token [token]'; +export const desc = 'Authenticate with a Nintendo Account session token'; + +export function builder(yargs: Argv) { + return yargs.positional('token', { + describe: 'Nintendo Account session token (it is recommended this is not set and you enter it interactively)', + type: 'string', + }).option('select', { + describe: 'Set as default user (default: true if only user)', + type: 'boolean', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + const storage = await initStorage(argv.dataPath); + + if (!argv.token) { + const read = await import('read'); + // @ts-expect-error + const prompt = util.promisify(read.default as typeof read); + + argv.token = await prompt({ + prompt: `Token: `, + silent: true, + output: process.stderr, + }); + } + + const {moon, data} = await getPctlToken(storage, argv.token); + + console.warn('Authenticated as Nintendo Account %s (%s)', + data.user.nickname, data.user.id); + + await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, argv.token); + + const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); + users.add(data.user.id); + await storage.setItem('NintendoAccountIds', [...users]); + + console.log('Saved token'); + + if ('select' in argv ? argv.select : users.size === 1) { + await storage.setItem('SelectedUser', data.user.id); + + console.log('Set as default user'); + } +} diff --git a/src/cli/pctl/user.ts b/src/cli/pctl/user.ts new file mode 100644 index 0000000..e76bb56 --- /dev/null +++ b/src/cli/pctl/user.ts @@ -0,0 +1,31 @@ +import createDebug from 'debug'; +import type { Arguments as ParentArguments } from '../../cli.js'; +import { ArgumentsCamelCase, Argv, getPctlToken, initStorage, YargsArguments } from '../../util.js'; + +const debug = createDebug('cli:pctl:user'); + +export const command = 'user'; +export const desc = 'Get the authenticated Nintendo Account'; + +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-pctl.' + usernsid); + const {moon, data} = await getPctlToken(storage, token); + + console.log('Nintendo Account', data.user); +} diff --git a/src/cli/users.ts b/src/cli/users.ts index bdf71de..5ba4996 100644 --- a/src/cli/users.ts +++ b/src/cli/users.ts @@ -2,7 +2,7 @@ import createDebug from 'debug'; // @ts-expect-error import Table from 'cli-table/lib/index.js'; import type { Arguments as ParentArguments } from '../cli.js'; -import { Argv, initStorage, SavedToken } from '../util.js'; +import { Argv, initStorage, SavedMoonToken, SavedToken } from '../util.js'; const debug = createDebug('cli:users'); @@ -24,22 +24,27 @@ export function builder(yargs: Argv) { 'Country', 'Nintendo Switch ID', 'Nintendo Switch username', + 'Parental Controls', ], }); for (const userid of users ?? []) { const token: string | undefined = await storage.getItem('NintendoAccountToken.' + userid); - if (!token) continue; - const cache: SavedToken | undefined = await storage.getItem('NsoToken.' + token); - if (!cache) continue; + const nsoCache: SavedToken | undefined = token ? await storage.getItem('NsoToken.' + token) : undefined; + const moonToken: string | undefined = await storage.getItem('NintendoAccountToken-pctl.' + userid); + const moonCache: SavedMoonToken | undefined = moonToken ? await storage.getItem('MoonToken.' + moonToken) : undefined; + + const user = nsoCache?.user ?? moonCache?.user; + if (!user) continue; table.push([ - cache.user.id + (selected === cache.user.id ? ' *' : ''), - cache.user.screenName, - cache.user.nickname, - cache.user.country, - cache.nsoAccount.user.nsaId, - cache.nsoAccount.user.name, + user.id + (selected === user.id ? ' *' : ''), + user.screenName, + user.nickname, + user.country, + nsoCache?.nsoAccount.user.nsaId ?? 'Not signed in', + nsoCache?.nsoAccount.user.name ?? 'Not signed in', + moonCache ? 'Signed in' : 'Not signed in', ]); } diff --git a/src/util.ts b/src/util.ts index cd22da0..8266f92 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import * as yargs from 'yargs'; import type * as yargstypes from '../node_modules/@types/yargs/index.js'; import createDebug from 'debug'; @@ -10,6 +11,7 @@ import { AccountLogin, CurrentUser, Game } from './api/znc-types.js'; import ZncApi from './api/znc.js'; import titles, { defaultTitle } from './titles.js'; import ZncProxyApi from './api/znc-proxy.js'; +import MoonApi from './api/moon.js'; const debug = createDebug('cli'); @@ -32,9 +34,16 @@ export interface SavedToken { proxy_url?: string; } +export interface SavedMoonToken { + nintendoAccountToken: NintendoAccountToken; + user: NintendoAccountUser; + + expires_at: number; +} + export async function initStorage(dir: string) { const storage = persist.create({ - dir, + dir: path.join(dir, 'persist'), stringify: data => JSON.stringify(data, null, 4) + '\n', }); await storage.init(); @@ -79,6 +88,40 @@ export async function getToken(storage: persist.LocalStorage, token: string, pro }; } +export async function getPctlToken(storage: persist.LocalStorage, token: string) { + if (!token) { + console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nintendo-znc pctl users add`.'); + throw new Error('Invalid token'); + } + + const existingToken: SavedMoonToken | undefined = await storage.getItem('MoonToken.' + token); + + if (!existingToken || existingToken.expires_at <= Date.now()) { + console.warn('Authenticating to Nintendo Switch Parental Controls app'); + debug('Authenticating to pctl with session token'); + + const {moon, data} = await MoonApi.createWithSessionToken(token); + + const existingToken: SavedMoonToken = { + ...data, + expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000), + }; + + await storage.setItem('MoonToken.' + token, existingToken); + await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token); + + return {moon, data: existingToken}; + } + + debug('Using existing token'); + await storage.setItem('NintendoAccountToken-pctl.' + existingToken.user.id, token); + + return { + moon: new MoonApi(existingToken.nintendoAccountToken.access_token!, existingToken.user.id), + data: existingToken, + }; +} + export function getTitleIdFromEcUrl(url: string) { const match = url.match(/^https:\/\/ec\.nintendo\.com\/apps\/([0-9a-f]{16})\//); return match?.[1] ?? null; @@ -93,9 +136,6 @@ export function getDiscordPresence(game: Game, friendcode?: CurrentUser['links'] const titleid = getTitleIdFromEcUrl(game.shopUri); const title = titles.find(t => t.id === titleid) || defaultTitle; - const hours = Math.floor(game.totalPlayTime / 60); - const minutes = game.totalPlayTime - (hours * 60); - const text = []; if (title.titleName === true) text.push(game.name); @@ -103,9 +143,10 @@ export function getDiscordPresence(game: Game, friendcode?: CurrentUser['links'] if (game.sysDescription) text.push(game.sysDescription); - if (hours >= 1) text.push('Played for ' + hours + ' hour' + (hours === 1 ? '' : 's') + - (minutes ? ', ' + minutes + ' minute' + (minutes === 1 ? '' : 's'): '') + - ' since ' + new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB')); + if (game.totalPlayTime >= 60) { + text.push('Played for ' + hrduration(game.totalPlayTime) + + ' since ' + new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB')); + } return { id: title.client || defaultTitle.client, @@ -138,3 +179,18 @@ export interface Title { smallImageKey?: string; showTimestamp?: boolean; } + +export function hrduration(duration: number, short = false) { + const hours = Math.floor(duration / 60); + const minutes = duration - (hours * 60); + + const hour_str = short ? 'hr' : 'hour'; + const minute_str = short ? 'min' : 'minute'; + + if (hours >= 1) { + return hours + ' ' + hour_str + (hours === 1 ? '' : 's') + + (minutes ? ', ' + minutes + ' ' + minute_str + (minutes === 1 ? '' : 's') : ''); + } else { + return minutes + ' ' + minute_str + (minutes === 1 ? '' : 's'); + } +}