mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-24 06:56:54 -05:00
Add NookLink commands
This commit is contained in:
parent
819bd02313
commit
b74994327c
252
src/api/nooklink.ts
Normal file
252
src/api/nooklink.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import fetch from 'node-fetch';
|
||||
import createDebug from 'debug';
|
||||
import { WebServiceToken } from './znc-types.js';
|
||||
import { NintendoAccountUser } from './na.js';
|
||||
import { ErrorResponse } from './util.js';
|
||||
import ZncApi from './znc.js';
|
||||
import { WebServiceError, Users, AuthToken, UserProfile, Newspapers, Newspaper, Emoticons, Reaction, IslandProfile } from './nooklink-types.js';
|
||||
|
||||
const debug = createDebug('api:nooklink');
|
||||
|
||||
export const NOOKLINK_WEBSERVICE_ID = '4953919198265344';
|
||||
export const NOOKLINK_WEBSERVICE_URL = 'https://web.sd.lp1.acbaa.srv.nintendo.net';
|
||||
export const NOOKLINK_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 NOOKLINK_URL = NOOKLINK_WEBSERVICE_URL + '/api';
|
||||
const BLANCO_VERSION = '2.1.0';
|
||||
|
||||
export default class NooklinkApi {
|
||||
constructor(
|
||||
public gtoken: string,
|
||||
public useragent: string
|
||||
) {}
|
||||
|
||||
async fetch<T = unknown>(url: string, method = 'GET', body?: string | FormData, headers?: object) {
|
||||
const response = await fetch(NOOKLINK_URL + url, {
|
||||
method: method,
|
||||
headers: Object.assign({
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'User-Agent': this.useragent,
|
||||
'Cookie': '_gtoken=' + encodeURIComponent(this.gtoken),
|
||||
'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',
|
||||
'Origin': 'https://web.sd.lp1.acbaa.srv.nintendo.net',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Blanco-Version': BLANCO_VERSION,
|
||||
}, headers),
|
||||
body: body,
|
||||
});
|
||||
|
||||
debug('fetch %s %s, response %s', method, url, response.status);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
const data = response.headers.get('Content-Type')?.match(/\bapplication\/json\b/i) ?
|
||||
await response.json() : await response.text();
|
||||
|
||||
throw new ErrorResponse('[nooklink] Unknown error', response, data);
|
||||
}
|
||||
|
||||
const data = await response.json() as T | WebServiceError;
|
||||
|
||||
if ('code' in data) {
|
||||
throw new ErrorResponse('[nooklink] Error ' + data.code, response, data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
return this.fetch<Users>('/sd/v1/users');
|
||||
}
|
||||
|
||||
async getAuthToken(user_id: string) {
|
||||
return this.fetch<AuthToken>('/sd/v1/auth_token', 'POST', JSON.stringify({
|
||||
userId: user_id,
|
||||
}));
|
||||
}
|
||||
|
||||
async createUserClient(user_id: string) {
|
||||
const token = await this.getAuthToken(user_id);
|
||||
|
||||
return {
|
||||
nooklinkuser: new NooklinkUserApi(user_id, token.token, this.gtoken, this.useragent),
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
static async createWithZnc(nso: ZncApi, user: NintendoAccountUser) {
|
||||
const data = await this.loginWithZnc(nso, user);
|
||||
|
||||
return {
|
||||
nooklink: new this(data.gtoken, data.useragent),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
static async loginWithZnc(nso: ZncApi, user: NintendoAccountUser) {
|
||||
const webserviceToken = await nso.getWebServiceToken(NOOKLINK_WEBSERVICE_ID);
|
||||
|
||||
return this.loginWithWebServiceToken(webserviceToken.result, user);
|
||||
}
|
||||
|
||||
static async loginWithWebServiceToken(webserviceToken: WebServiceToken, user: NintendoAccountUser) {
|
||||
const url = new URL(NOOKLINK_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': NOOKLINK_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(/\b_gtoken=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i);
|
||||
|
||||
if (!match) {
|
||||
throw new ErrorResponse('Response didn\'t include _gtoken cookie', response);
|
||||
}
|
||||
|
||||
const gtoken = decodeURIComponent(match[1]);
|
||||
const expires = decodeURIComponent(match[8] || '')
|
||||
.replace(/(\b)(\d{1,2})-([a-z]{3})-(\d{4})(\b)/gi, '$1$2 $3 $4$5');
|
||||
|
||||
debug('_gtoken %s, expires %s', gtoken, expires);
|
||||
|
||||
const expires_at = expires ? Date.parse(expires) : Date.now() + webserviceToken.expiresIn * 1000;
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
return {
|
||||
webserviceToken,
|
||||
url: url.toString(),
|
||||
cookies: cookies!,
|
||||
body,
|
||||
|
||||
gtoken,
|
||||
expires_at,
|
||||
useragent: NOOKLINK_WEBSERVICE_USERAGENT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class NooklinkUserApi {
|
||||
constructor(
|
||||
public user_id: string,
|
||||
public auth_token: string,
|
||||
public gtoken: string,
|
||||
public useragent: string,
|
||||
public language = 'en-GB'
|
||||
) {}
|
||||
|
||||
async fetch<T = unknown>(url: string, method = 'GET', body?: string | FormData, headers?: object) {
|
||||
const response = await fetch(NOOKLINK_URL + url, {
|
||||
method: method,
|
||||
headers: Object.assign({
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'User-Agent': this.useragent,
|
||||
'Cookie': '_gtoken=' + encodeURIComponent(this.gtoken),
|
||||
'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',
|
||||
'Origin': 'https://web.sd.lp1.acbaa.srv.nintendo.net',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + this.auth_token,
|
||||
'X-Blanco-Version': BLANCO_VERSION,
|
||||
}, headers),
|
||||
body: body,
|
||||
});
|
||||
|
||||
debug('fetch %s %s, response %s', method, url, response.status);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
const data = response.headers.get('Content-Type')?.match(/\bapplication\/json\b/i) ?
|
||||
await response.json() : await response.text();
|
||||
|
||||
throw new ErrorResponse('[nooklink] Unknown error', response, data);
|
||||
}
|
||||
|
||||
const data = await response.json() as T | WebServiceError;
|
||||
|
||||
if ('code' in data) {
|
||||
throw new ErrorResponse('[nooklink] Error ' + data.code, response, data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getUserProfile(id?: string) {
|
||||
return this.fetch<UserProfile>('/sd/v1/users/' + (id ?? this.user_id) + '/profile?language=' + this.language);
|
||||
}
|
||||
|
||||
async getIslandProfile(id: string) {
|
||||
return this.fetch<IslandProfile>('/sd/v1/lands/' + id + '/profile?language=' + this.language);
|
||||
}
|
||||
|
||||
async getNewspapers() {
|
||||
return this.fetch<Newspapers>('/sd/v1/newspapers');
|
||||
}
|
||||
|
||||
async getNewspaper(key: string) {
|
||||
const requestedAt = formatDateTime(new Date());
|
||||
|
||||
return this.fetch<Newspaper>('/sd/v1/newspapers/' + key + '?requestedAt=' + requestedAt + '&language=' + this.language);
|
||||
}
|
||||
|
||||
async getLatestNewspaper() {
|
||||
const requestedAt = formatDateTime(new Date());
|
||||
|
||||
return this.fetch<Newspaper>('/sd/v1/newspapers/latest?requestedAt=' + requestedAt + '&language=' + this.language);
|
||||
}
|
||||
|
||||
async postMessage(body: string, type: MessageType, destination_user_id?: string) {
|
||||
return this.fetch<unknown>('/sd/v1/messages', 'POST', JSON.stringify({
|
||||
type,
|
||||
body,
|
||||
userId: destination_user_id,
|
||||
}));
|
||||
}
|
||||
|
||||
async keyboard(message: string) {
|
||||
return this.postMessage(message, MessageType.KEYBOARD);
|
||||
}
|
||||
|
||||
async getEmoticons() {
|
||||
return this.fetch<Emoticons>('/sd/v1/emoticons?language=' + this.language);
|
||||
}
|
||||
|
||||
async reaction(reaction: Reaction) {
|
||||
return this.postMessage(reaction.label, MessageType.EMOTICON);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date) {
|
||||
return date.getFullYear().toString().padStart(4, '0') + '-' +
|
||||
(date.getMonth() + 1).toString().padStart(2, '0') + '-' +
|
||||
date.getDate().toString().padStart(2, '0') + ' ' +
|
||||
date.getHours().toString().padStart(2, '0') + ':' +
|
||||
date.getMinutes().toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
export enum MessageType {
|
||||
KEYBOARD = 'keyboard',
|
||||
EMOTICON = 'emoticon',
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export * as users from './users.js';
|
||||
export * as nso from './nso.js';
|
||||
export * as splatnet2 from './splatnet2.js';
|
||||
export * as nooklink from './nooklink.js';
|
||||
export * as pctl from './pctl.js';
|
||||
export * as androidZncaApiServerFrida from './android-znca-api-server-frida.js';
|
||||
export * as app from './app.js';
|
||||
|
|
|
|||
28
src/cli/nooklink.ts
Normal file
28
src/cli/nooklink.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import createDebug from 'debug';
|
||||
import type { Arguments as ParentArguments } from '../cli.js';
|
||||
import { Argv, YargsArguments } from '../util.js';
|
||||
import * as commands from './nooklink/index.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink');
|
||||
|
||||
export const command = 'nooklink <command>';
|
||||
export const desc = 'NookLink';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
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',
|
||||
default: process.env.ZNC_PROXY_URL,
|
||||
}).option('auto-update-session', {
|
||||
describe: 'Automatically obtain and refresh the NookLink game web token and user token',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
});
|
||||
}
|
||||
|
||||
export type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
66
src/cli/nooklink/dump-newspapers.ts
Normal file
66
src/cli/nooklink/dump-newspapers.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import createDebug from 'debug';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import mkdirp from 'mkdirp';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getUserToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:dump-newspapers');
|
||||
|
||||
export const command = 'dump-newspapers [directory]';
|
||||
export const desc = 'Download all newspaper articles';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.positional('directory', {
|
||||
describe: 'Directory to write record data to',
|
||||
type: 'string',
|
||||
}).option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('islander', {
|
||||
describe: 'NookLink user ID',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
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 {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
|
||||
const directory = argv.directory ?? path.join(argv.dataPath, 'nooklink');
|
||||
|
||||
await mkdirp(directory);
|
||||
|
||||
const latest = await nooklinkuser.getLatestNewspaper();
|
||||
const newspapers = await nooklinkuser.getNewspapers();
|
||||
|
||||
for (const item of newspapers.newspapers) {
|
||||
const is_latest = item.findKey === latest.findKey;
|
||||
|
||||
const filename = 'nooklink-newspaper-' + nooklinkuser.user_id + '-' + item.beginDate + '-' + item.findKey +
|
||||
(is_latest ? '-' + Date.now() : '') + '.json';
|
||||
const file = path.join(directory, filename);
|
||||
|
||||
try {
|
||||
await fs.stat(file);
|
||||
debug('Skipping newspaper %s, date %s, file already exists', item.findKey, item.beginDate);
|
||||
continue;
|
||||
} catch (err) {}
|
||||
|
||||
if (!is_latest) debug('Fetching newspaper %s, date %s', item.findKey, item.beginDate);
|
||||
const newspaper = is_latest ? latest : await nooklinkuser.getNewspaper(item.findKey);
|
||||
|
||||
debug('Writing %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify(newspaper, null, 4) + '\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
9
src/cli/nooklink/index.ts
Normal file
9
src/cli/nooklink/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export * as users from './users.js';
|
||||
export * as user from './user.js';
|
||||
export * as island from './island.js';
|
||||
export * as newspapers from './newspapers.js';
|
||||
export * as newspaper from './newspaper.js';
|
||||
export * as dumpNewspapers from './dump-newspapers.js';
|
||||
export * as keyboard from './keyboard.js';
|
||||
export * as reactions from './reactions.js';
|
||||
export * as postReaction from './post-reaction.js';
|
||||
85
src/cli/nooklink/island.ts
Normal file
85
src/cli/nooklink/island.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import createDebug from 'debug';
|
||||
// @ts-expect-error
|
||||
import Table from 'cli-table/lib/index.js';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getUserToken, getWebServiceToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:island');
|
||||
|
||||
export const command = 'island';
|
||||
export const desc = 'Get the player\'s passport data (island information)';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('islander', {
|
||||
describe: 'NookLink user ID',
|
||||
type: 'string',
|
||||
}).option('json', {
|
||||
describe: 'Output raw JSON',
|
||||
type: 'boolean',
|
||||
}).option('json-pretty-print', {
|
||||
describe: 'Output pretty-printed JSON',
|
||||
type: 'boolean',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
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 {nooklink, data: wstoken} = await getWebServiceToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl);
|
||||
|
||||
const users = await nooklink.getUsers();
|
||||
const user = users.users.find(u => u.id === nooklinkuser.user_id)!;
|
||||
|
||||
const island = await nooklinkuser.getIslandProfile(user.land.id);
|
||||
|
||||
if (argv.jsonPrettyPrint) {
|
||||
console.log(JSON.stringify(island, null, 4));
|
||||
return;
|
||||
}
|
||||
if (argv.json) {
|
||||
console.log(JSON.stringify(island));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Island', {
|
||||
...island,
|
||||
mNormalNpc: undefined,
|
||||
mVillager: undefined,
|
||||
});
|
||||
|
||||
const table = new Table({
|
||||
head: [
|
||||
'Type',
|
||||
'Name',
|
||||
'Birthday',
|
||||
'NookLink user ID',
|
||||
],
|
||||
});
|
||||
|
||||
for (const villager of [...island.mVillager, ...island.mNormalNpc]) {
|
||||
table.push([
|
||||
'mPNm' in villager ? villager.mIsLandMaster ? 'Resident Representative' : 'Player' : 'NPC',
|
||||
'mPNm' in villager ? villager.mPNm : villager.name,
|
||||
'mPNm' in villager ?
|
||||
villager.mBirthDay + '/' + villager.mBirthMonth :
|
||||
villager.birthDay + '/' + villager.birthMonth,
|
||||
'mPNm' in villager ? villager.userId ?? '' : '',
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Residents');
|
||||
console.log(table.toString());
|
||||
}
|
||||
55
src/cli/nooklink/keyboard.ts
Normal file
55
src/cli/nooklink/keyboard.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { promisify } from 'util';
|
||||
import createDebug from 'debug';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getUserToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:keyboard');
|
||||
|
||||
export const command = 'keyboard [message]';
|
||||
export const desc = 'Send a message in an online Animal Crossing: New Horizons session';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.positional('message', {
|
||||
describe: 'Message text',
|
||||
type: 'string',
|
||||
}).option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('islander', {
|
||||
describe: 'NookLink user ID',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
if (!argv.message) {
|
||||
const read = await import('read');
|
||||
// @ts-expect-error
|
||||
const prompt = promisify(read.default as typeof read);
|
||||
|
||||
argv.message = await prompt({
|
||||
prompt: `Message: `,
|
||||
output: process.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
if (!argv.message) return;
|
||||
if (argv.message?.length > 32) {
|
||||
throw new Error('Message must be less than or equal to 32 characters');
|
||||
}
|
||||
|
||||
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 {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
|
||||
await nooklinkuser.keyboard(argv.message);
|
||||
}
|
||||
86
src/cli/nooklink/newspaper.ts
Normal file
86
src/cli/nooklink/newspaper.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import createDebug from 'debug';
|
||||
// @ts-expect-error
|
||||
import Table from 'cli-table/lib/index.js';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getUserToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:newspaper');
|
||||
|
||||
export const command = 'newspaper [key]';
|
||||
export const desc = 'Get a newspaper issue';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.positional('key', {
|
||||
describe: 'Newspaper ID',
|
||||
type: 'string',
|
||||
}).option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('islander', {
|
||||
describe: 'NookLink user ID',
|
||||
type: 'string',
|
||||
}).option('json', {
|
||||
describe: 'Output raw JSON',
|
||||
type: 'boolean',
|
||||
}).option('json-pretty-print', {
|
||||
describe: 'Output pretty-printed JSON',
|
||||
type: 'boolean',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
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 {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
|
||||
const newspaper = argv.key ?
|
||||
await nooklinkuser.getNewspaper(argv.key) :
|
||||
await nooklinkuser.getLatestNewspaper();
|
||||
|
||||
if (argv.jsonPrettyPrint) {
|
||||
console.log(JSON.stringify(newspaper, null, 4));
|
||||
return;
|
||||
}
|
||||
if (argv.json) {
|
||||
console.log(JSON.stringify(newspaper));
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: [
|
||||
'Date',
|
||||
'Priority',
|
||||
'Type',
|
||||
'Attributes',
|
||||
],
|
||||
});
|
||||
|
||||
for (const article of newspaper.body.articles) {
|
||||
table.push([
|
||||
article.date,
|
||||
article.priority,
|
||||
article.label,
|
||||
article.attributes.map(at => {
|
||||
if (at.type === 'date') return at.type + ' ' + at.value;
|
||||
if (at.type === 'npc') return at.type + ' ' + at.value;
|
||||
if (at.type === 'item') return at.type + ' ' + at.value;
|
||||
if (at.type === 'rand') return at.type + ' ' + at.value;
|
||||
if (at.type === 'player') return at.type + ' ' + at.value;
|
||||
if (at.type === 'value') return at.type + ' ' + at.value;
|
||||
return (at as any).type;
|
||||
}).join('\n'),
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Articles');
|
||||
console.log(table.toString());
|
||||
}
|
||||
73
src/cli/nooklink/newspapers.ts
Normal file
73
src/cli/nooklink/newspapers.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import createDebug from 'debug';
|
||||
// @ts-expect-error
|
||||
import Table from 'cli-table/lib/index.js';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getUserToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:newspapers');
|
||||
|
||||
export const command = 'newspapers';
|
||||
export const desc = 'List all newspaper issues';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('islander', {
|
||||
describe: 'NookLink user ID',
|
||||
type: 'string',
|
||||
}).option('json', {
|
||||
describe: 'Output raw JSON',
|
||||
type: 'boolean',
|
||||
}).option('json-pretty-print', {
|
||||
describe: 'Output pretty-printed JSON',
|
||||
type: 'boolean',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
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 {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
|
||||
const latest = await nooklinkuser.getLatestNewspaper();
|
||||
const newspapers = await nooklinkuser.getNewspapers();
|
||||
|
||||
if (argv.jsonPrettyPrint) {
|
||||
console.log(JSON.stringify(newspapers, null, 4));
|
||||
return;
|
||||
}
|
||||
if (argv.json) {
|
||||
console.log(JSON.stringify(newspapers));
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: [
|
||||
'ID',
|
||||
'Type',
|
||||
'Start date',
|
||||
'End date',
|
||||
],
|
||||
});
|
||||
|
||||
for (const newspaper of newspapers.newspapers) {
|
||||
table.push([
|
||||
newspaper.findKey + (newspaper.findKey === latest.findKey ? ' *' : ''),
|
||||
newspaper.type,
|
||||
newspaper.beginDate,
|
||||
newspaper.endDate,
|
||||
]);
|
||||
}
|
||||
|
||||
console.log(table.toString());
|
||||
}
|
||||
46
src/cli/nooklink/post-reaction.ts
Normal file
46
src/cli/nooklink/post-reaction.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import createDebug from 'debug';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getUserToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:post-reaction');
|
||||
|
||||
export const command = 'post-reaction <reaction>';
|
||||
export const desc = 'Send a reaction in an online Animal Crossing: New Horizons session';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.positional('reaction', {
|
||||
describe: 'Reaction ID',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}).option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('islander', {
|
||||
describe: 'NookLink user ID',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
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 {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
|
||||
const emoticons = await nooklinkuser.getEmoticons();
|
||||
const reaction = emoticons.emoticons.find(r => r.label.toLowerCase() === argv.reaction.toLowerCase());
|
||||
|
||||
if (!reaction) {
|
||||
throw new Error('Unknown reaction "' + argv.reaction + '"');
|
||||
}
|
||||
|
||||
await nooklinkuser.reaction(reaction);
|
||||
}
|
||||
68
src/cli/nooklink/reactions.ts
Normal file
68
src/cli/nooklink/reactions.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import createDebug from 'debug';
|
||||
// @ts-expect-error
|
||||
import Table from 'cli-table/lib/index.js';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getUserToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:reactions');
|
||||
|
||||
export const command = 'reactions';
|
||||
export const desc = 'List all reactions available to the authenticated user/player';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('islander', {
|
||||
describe: 'NookLink user ID',
|
||||
type: 'string',
|
||||
}).option('json', {
|
||||
describe: 'Output raw JSON',
|
||||
type: 'boolean',
|
||||
}).option('json-pretty-print', {
|
||||
describe: 'Output pretty-printed JSON',
|
||||
type: 'boolean',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
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 {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
|
||||
const emoticons = await nooklinkuser.getEmoticons();
|
||||
|
||||
if (argv.jsonPrettyPrint) {
|
||||
console.log(JSON.stringify(emoticons, null, 4));
|
||||
return;
|
||||
}
|
||||
if (argv.json) {
|
||||
console.log(JSON.stringify(emoticons));
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: [
|
||||
'ID',
|
||||
'Name',
|
||||
],
|
||||
});
|
||||
|
||||
for (const emoticon of emoticons.emoticons) {
|
||||
table.push([
|
||||
emoticon.label,
|
||||
emoticon.name,
|
||||
]);
|
||||
}
|
||||
|
||||
console.log(table.toString());
|
||||
}
|
||||
52
src/cli/nooklink/user.ts
Normal file
52
src/cli/nooklink/user.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import createDebug from 'debug';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getUserToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:user');
|
||||
|
||||
export const command = 'user';
|
||||
export const desc = 'Get the player\'s passport data (player information)';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('islander', {
|
||||
describe: 'NookLink user ID',
|
||||
type: 'string',
|
||||
}).option('json', {
|
||||
describe: 'Output raw JSON',
|
||||
type: 'boolean',
|
||||
}).option('json-pretty-print', {
|
||||
describe: 'Output pretty-printed JSON',
|
||||
type: 'boolean',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
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 {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
|
||||
const profile = await nooklinkuser.getUserProfile(data.user);
|
||||
|
||||
if (argv.jsonPrettyPrint) {
|
||||
console.log(JSON.stringify(profile, null, 4));
|
||||
return;
|
||||
}
|
||||
if (argv.json) {
|
||||
console.log(JSON.stringify(profile));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('User', profile);
|
||||
}
|
||||
69
src/cli/nooklink/users.ts
Normal file
69
src/cli/nooklink/users.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import createDebug from 'debug';
|
||||
// @ts-expect-error
|
||||
import Table from 'cli-table/lib/index.js';
|
||||
import type { Arguments as ParentArguments } from '../nooklink.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getWebServiceToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink:users');
|
||||
|
||||
export const command = 'users';
|
||||
export const desc = 'List the authenticated user\'s NookLink enabled players';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('json', {
|
||||
describe: 'Output raw JSON',
|
||||
type: 'boolean',
|
||||
}).option('json-pretty-print', {
|
||||
describe: 'Output pretty-printed JSON',
|
||||
type: 'boolean',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
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 {nooklink} = await getWebServiceToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
|
||||
|
||||
const users = await nooklink.getUsers();
|
||||
|
||||
if (argv.jsonPrettyPrint) {
|
||||
console.log(JSON.stringify(users.users, null, 4));
|
||||
return;
|
||||
}
|
||||
if (argv.json) {
|
||||
console.log(JSON.stringify(users.users));
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: [
|
||||
'ID',
|
||||
'Name',
|
||||
'Island ID',
|
||||
'Island name',
|
||||
],
|
||||
});
|
||||
|
||||
for (const user of users.users) {
|
||||
table.push([
|
||||
user.id,
|
||||
user.name,
|
||||
user.land.id,
|
||||
user.land.name,
|
||||
]);
|
||||
}
|
||||
|
||||
console.log(table.toString());
|
||||
}
|
||||
137
src/cli/nooklink/util.ts
Normal file
137
src/cli/nooklink/util.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import createDebug from 'debug';
|
||||
import persist from 'node-persist';
|
||||
import { getToken } from '../../util.js';
|
||||
import NooklinkApi, { NooklinkUserApi } from '../../api/nooklink.js';
|
||||
import { AuthToken, Users } from '../../api/nooklink-types.js';
|
||||
import { WebServiceToken } from '../../api/znc-types.js';
|
||||
|
||||
const debug = createDebug('cli:nooklink');
|
||||
|
||||
export interface SavedToken {
|
||||
webserviceToken: WebServiceToken;
|
||||
url: string;
|
||||
cookies: string;
|
||||
body: string;
|
||||
|
||||
gtoken: string;
|
||||
expires_at: number;
|
||||
useragent: string;
|
||||
}
|
||||
|
||||
export async function getWebServiceToken(
|
||||
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: SavedToken | undefined = await storage.getItem('NookToken.' + token);
|
||||
|
||||
if (!existingToken || existingToken.expires_at <= Date.now()) {
|
||||
if (!allow_fetch_token) {
|
||||
throw new Error('No valid _gtoken cookie');
|
||||
}
|
||||
|
||||
console.warn('Authenticating to NookLink');
|
||||
debug('Authenticating to NookLink');
|
||||
|
||||
const {nso, data} = await getToken(storage, token, proxy_url);
|
||||
|
||||
const existingToken: SavedToken = await NooklinkApi.loginWithZnc(nso, data.user);
|
||||
|
||||
await storage.setItem('NookToken.' + token, existingToken);
|
||||
|
||||
return {
|
||||
nooklink: new NooklinkApi(existingToken.gtoken, existingToken.useragent),
|
||||
data: existingToken,
|
||||
};
|
||||
}
|
||||
|
||||
debug('Using existing web service token');
|
||||
|
||||
return {
|
||||
nooklink: new NooklinkApi(existingToken.gtoken, existingToken.useragent),
|
||||
data: existingToken,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SavedUserToken {
|
||||
token: AuthToken;
|
||||
user: string;
|
||||
webserviceToken: SavedToken;
|
||||
}
|
||||
|
||||
type PromiseValue<T> = T extends PromiseLike<infer R> ? R : never;
|
||||
|
||||
export async function getUserToken(
|
||||
storage: persist.LocalStorage, nintendoAccountToken: string, user?: string,
|
||||
proxy_url?: string, allow_fetch_token = false
|
||||
) {
|
||||
let wst: PromiseValue<ReturnType<typeof getWebServiceToken>> | null = null;
|
||||
|
||||
if (!user) {
|
||||
let cachedUsers: {
|
||||
users: Users;
|
||||
expires_at: number;
|
||||
} | undefined = await storage.getItem('NookUsers.' + nintendoAccountToken);
|
||||
|
||||
if (!cachedUsers || cachedUsers.expires_at <= Date.now()) {
|
||||
if (!wst) wst = await getWebServiceToken(storage, nintendoAccountToken, proxy_url, allow_fetch_token);
|
||||
const {nooklink, data: webserviceToken} = wst;
|
||||
|
||||
debug('Fetching users');
|
||||
const users = await nooklink.getUsers();
|
||||
|
||||
await storage.setItem('NookUsers.' + nintendoAccountToken, cachedUsers = {
|
||||
users,
|
||||
expires_at: webserviceToken.expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
if (!cachedUsers.users.users.length) {
|
||||
throw new Error('No Animal Crossing: New Horizons save data linked to NookLink');
|
||||
}
|
||||
|
||||
if (cachedUsers.users.users.length > 1) {
|
||||
console.warn('More than 1 NookLink user is linked to this Nintendo Account. The first player will be used. Use `--islander` to set a specific user.');
|
||||
}
|
||||
|
||||
user = cachedUsers.users.users[0].id;
|
||||
}
|
||||
|
||||
const existingToken: SavedUserToken | undefined = await storage.getItem('NookAuthToken.' + nintendoAccountToken + '.' + user);
|
||||
|
||||
if (!existingToken || existingToken.token.expireAt <= (Date.now() / 1000)) {
|
||||
if (!wst) wst = await getWebServiceToken(storage, nintendoAccountToken, proxy_url, allow_fetch_token);
|
||||
const {nooklink, data: webserviceToken} = wst;
|
||||
|
||||
console.warn('Authenticating to NookLink as user %s', user);
|
||||
debug('Authenticating to NookLink as user %s', user);
|
||||
|
||||
const token = await nooklink.getAuthToken(user);
|
||||
|
||||
const existingToken: SavedUserToken = {
|
||||
token,
|
||||
user,
|
||||
webserviceToken,
|
||||
};
|
||||
|
||||
await storage.setItem('NookAuthToken.' + nintendoAccountToken + '.' + user, existingToken);
|
||||
|
||||
return {
|
||||
nooklinkuser: new NooklinkUserApi(user, token.token, nooklink.gtoken, nooklink.useragent),
|
||||
data: existingToken,
|
||||
};
|
||||
}
|
||||
|
||||
debug('Using existing NookLink auth token');
|
||||
|
||||
return {
|
||||
nooklinkuser: new NooklinkUserApi(
|
||||
user, existingToken.token.token,
|
||||
existingToken.webserviceToken.gtoken, existingToken.webserviceToken.useragent
|
||||
),
|
||||
data: existingToken,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user