Add NookLink commands

This commit is contained in:
Samuel Elliott
2022-04-18 14:43:34 +01:00
parent 819bd02313
commit b74994327c
14 changed files with 1027 additions and 0 deletions

View File

@@ -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
View 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>>;

View 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');
}
}

View 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';

View 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());
}

View 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);
}

View 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());
}

View 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());
}

View 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);
}

View 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
View 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
View 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
View 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,
};
}