mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-07-05 17:41:38 -05:00
Add HTTP server
This commit is contained in:
parent
0bdd4b4614
commit
c0c138ef49
1086
package-lock.json
generated
1086
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -21,6 +21,7 @@
|
|||
"cli-table": "^0.3.11",
|
||||
"debug": "^4.3.3",
|
||||
"discord-rpc": "^4.0.1",
|
||||
"express": "^4.17.3",
|
||||
"node-fetch": "^3.2.2",
|
||||
"node-notifier": "^10.0.1",
|
||||
"node-persist": "^3.1.0",
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"@types/cli-table": "^0.3.0",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/discord-rpc": "^4.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/node-notifier": "^8.0.2",
|
||||
"@types/node-persist": "^3.1.2",
|
||||
|
|
|
|||
81
src/api/znc-proxy.ts
Normal file
81
src/api/znc-proxy.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import fetch from 'node-fetch';
|
||||
import createDebug from 'debug';
|
||||
import { ActiveEvent, Announcement, CurrentUser, Friend, WebService, WebServiceToken } from './znc-types.js';
|
||||
import { ErrorResponse } from './util.js';
|
||||
import ZncApi from './znc.js';
|
||||
import { SavedToken } from '../util.js';
|
||||
|
||||
const debug = createDebug('api:znc-proxy');
|
||||
|
||||
export default class ZncProxyApi implements ZncApi {
|
||||
constructor(
|
||||
private url: string,
|
||||
// ZncApi uses the NSO token (valid for a few hours)
|
||||
// ZncProxyApi uses the Nintendo Account session token (valid for two years)
|
||||
public token: string
|
||||
) {}
|
||||
|
||||
async fetch<T = unknown>(url: string, method = 'GET', body?: string, headers?: object) {
|
||||
const response = await fetch(this.url + url, {
|
||||
method: method,
|
||||
headers: Object.assign({
|
||||
'Authorization': 'na ' + this.token,
|
||||
}, headers),
|
||||
body: body,
|
||||
});
|
||||
|
||||
debug('fetch %s %s, response %s', method, url, response.status);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new ErrorResponse('[zncproxy] Unknown error', response);
|
||||
}
|
||||
|
||||
const data = await response.json() as T;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getAnnouncements() {
|
||||
const response = await this.fetch<{announcements: Announcement[]}>('/announcements');
|
||||
return {status: 0 as const, result: response.announcements, correlationId: ''};
|
||||
}
|
||||
|
||||
async getFriendList() {
|
||||
const response = await this.fetch<{friends: Friend[]}>('/friends');
|
||||
return {status: 0 as const, result: response, correlationId: ''};
|
||||
}
|
||||
|
||||
async getWebServices() {
|
||||
const response = await this.fetch<{webservices: WebService[]}>('/webservices');
|
||||
return {status: 0 as const, result: response.webservices, correlationId: ''};
|
||||
}
|
||||
|
||||
async getActiveEvent() {
|
||||
const response = await this.fetch<{activeevent: ActiveEvent}>('/activeevent');
|
||||
return {status: 0 as const, result: response.activeevent, correlationId: ''};
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
const response = await this.fetch<{user: CurrentUser}>('/user');
|
||||
return {status: 0 as const, result: response.user, correlationId: ''};
|
||||
}
|
||||
|
||||
async getWebServiceToken(id: string) {
|
||||
const response = await this.fetch<{token: WebServiceToken}>('/webservice/' + id + '/token');
|
||||
return {status: 0 as const, result: response.token, correlationId: ''};
|
||||
}
|
||||
|
||||
async renewToken() {
|
||||
const data = await this.fetch<SavedToken>('/auth');
|
||||
data.proxy_url = this.url;
|
||||
return data;
|
||||
}
|
||||
|
||||
static async createWithSessionToken(url: string, token: string) {
|
||||
const nso = new this(url, token);
|
||||
const data = await nso.fetch<SavedToken>('/auth');
|
||||
data.proxy_url = url;
|
||||
|
||||
return {nso, data};
|
||||
}
|
||||
}
|
||||
|
|
@ -106,10 +106,9 @@ export interface CurrentUser {
|
|||
links: {
|
||||
nintendoAccount: {
|
||||
membership: {
|
||||
// active: {
|
||||
// active: boolean;
|
||||
// };
|
||||
active: boolean;
|
||||
active: {
|
||||
active: boolean;
|
||||
} | boolean;
|
||||
};
|
||||
};
|
||||
friendCode: {
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ const ZNC_URL = 'https://api-lp1.znc.srv.nintendo.net';
|
|||
|
||||
export default class ZncApi {
|
||||
constructor(
|
||||
private token: string
|
||||
public token: string
|
||||
) {}
|
||||
|
||||
protected async fetch<T = unknown>(url: string, method = 'GET', body?: string, headers?: object) {
|
||||
async fetch<T = unknown>(url: string, method = 'GET', body?: string, headers?: object) {
|
||||
const response = await fetch(ZNC_URL + url, {
|
||||
method: method,
|
||||
headers: Object.assign({
|
||||
|
|
@ -33,6 +33,8 @@ export default class ZncApi {
|
|||
body: body,
|
||||
});
|
||||
|
||||
debug('fetch %s %s, response %s', method, url, response.status);
|
||||
|
||||
const data = await response.json() as ZncResponse<T>;
|
||||
|
||||
if ('errorMessage' in data) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ const yargs = Yargs(process.argv.slice(2)).option('data-path', {
|
|||
describe: 'Data storage path',
|
||||
type: 'string',
|
||||
default: path.join(import.meta.url.substr(7), '..', '..', 'data'),
|
||||
}).option('znc-proxy-url', {
|
||||
describe: 'URL of Nintendo Switch Online app API proxy server to use',
|
||||
type: 'string',
|
||||
});
|
||||
|
||||
export type Arguments = YargsArguments<typeof yargs>;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
|
||||
await storage.getItem('SessionToken');
|
||||
const {nso, data} = await getToken(storage, token);
|
||||
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
|
||||
|
||||
const announcements = await nso.getAnnouncements();
|
||||
const friends = await nso.getFriendList();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
|
||||
await storage.getItem('SessionToken');
|
||||
const {nso, data} = await getToken(storage, token);
|
||||
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
|
||||
|
||||
const announcements = await nso.getAnnouncements();
|
||||
const friends = await nso.getFriendList();
|
||||
|
|
|
|||
384
src/cli/http-server.ts
Normal file
384
src/cli/http-server.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
import createDebug from 'debug';
|
||||
import express from 'express';
|
||||
import * as net from 'net';
|
||||
import persist from 'node-persist';
|
||||
import { ActiveEvent, Announcement, CurrentUser, Friend, Presence, WebService } from '../api/znc-types.js';
|
||||
import ZncApi from '../api/znc.js';
|
||||
import type { Arguments as ParentArguments } from '../cli.js';
|
||||
import { ArgumentsCamelCase, Argv, getToken, initStorage, SavedToken, YargsArguments } from '../util.js';
|
||||
import { ZncNotifications } from './notify.js';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
znc?: ZncApi;
|
||||
zncAuth?: SavedToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debug = createDebug('cli:http-server');
|
||||
|
||||
export const command = 'http-server';
|
||||
export const desc = 'Starts a HTTP server to access the Nintendo Switch Online app API';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.option('listen', {
|
||||
describe: 'Server address and port',
|
||||
type: 'array',
|
||||
default: ['[::]:0'],
|
||||
}).option('require-token', {
|
||||
describe: 'Require Nintendo Account session token for all requests (if disabled the user query string parameter can be used to use the last token for that user)',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
}).option('update-interval', {
|
||||
describe: 'Max. update interval in seconds',
|
||||
type: 'number',
|
||||
default: 30,
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
const updateInterval = argv.updateInterval * 1000;
|
||||
|
||||
const storage = await initStorage(argv.dataPath);
|
||||
|
||||
const app = express();
|
||||
|
||||
const localAuth: express.RequestHandler = async (req, res, next) => {
|
||||
if (argv.requireToken || !req.query.user) return next();
|
||||
|
||||
const token = await storage.getItem('NintendoAccountToken.' + req.query.user);
|
||||
if (!token) return next();
|
||||
|
||||
req.headers['authorization'] = 'na ' + token;
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
const nsoAuth: express.RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const auth = req.headers['authorization'];
|
||||
if (!auth || !auth.startsWith('na ')) throw new Error('Requires Nintendo Account authentication');
|
||||
const nintendoAccountSessionToken = auth.substr(3);
|
||||
|
||||
const {nso, data} = await getToken(storage, nintendoAccountSessionToken, argv.zncProxyUrl);
|
||||
|
||||
req.znc = nso;
|
||||
req.zncAuth = data;
|
||||
|
||||
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
|
||||
users.add(data.user.id);
|
||||
await storage.setItem('NintendoAccountIds', [...users]);
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: err,
|
||||
error_message: (err as Error).message,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/znc/auth', nsoAuth, (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(req.zncAuth));
|
||||
});
|
||||
|
||||
//
|
||||
// Announcements
|
||||
// This is cached for all users.
|
||||
//
|
||||
|
||||
let cached_announcements: Announcement[] | null = null;
|
||||
app.get('/api/znc/announcements', localAuth, nsoAuth, async (req, res) => {
|
||||
if (cached_announcements) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
announcements: cached_announcements,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const announcements = await req.znc!.getAnnouncements();
|
||||
cached_announcements = announcements.result;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
announcements: announcements.result,
|
||||
}));
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: err,
|
||||
error_message: (err as Error).message,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Nintendo Switch friends, NSO app web services, events
|
||||
//
|
||||
|
||||
const cached_appdata = new Map<string, [Friend[], WebService[], ActiveEvent, number]>();
|
||||
|
||||
const getAppData: express.RequestHandler = async (req, res, next) => {
|
||||
const cache = cached_appdata.get(req.zncAuth!.user.id);
|
||||
|
||||
if (cache && ((cache[3] + updateInterval) > Date.now())) {
|
||||
debug('Using cached app data for %s', req.zncAuth!.user.id);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const friends = await req.znc!.getFriendList();
|
||||
const webservices = await req.znc!.getWebServices();
|
||||
const activeevent = await req.znc!.getActiveEvent();
|
||||
|
||||
cached_appdata.set(req.zncAuth!.user.id, [
|
||||
friends.result.friends, webservices.result, activeevent.result, Date.now(),
|
||||
]);
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: err,
|
||||
error_message: (err as Error).message,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/znc/friends', localAuth, nsoAuth, getAppData, async (req, res) => {
|
||||
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({friends, updated}));
|
||||
});
|
||||
|
||||
app.get('/api/znc/friends/presence', localAuth, nsoAuth, getAppData, async (req, res) => {
|
||||
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
|
||||
const presence: Record<string, Presence> = {};
|
||||
|
||||
for (const friend of friends) {
|
||||
presence[friend.nsaId] = friend.presence;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(presence));
|
||||
});
|
||||
|
||||
app.get('/api/znc/friend/:nsid', localAuth, nsoAuth, getAppData, async (req, res) => {
|
||||
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
|
||||
const friend = friends.find(f => f.nsaId === req.params.nsid);
|
||||
|
||||
if (!friend) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: 'not_found',
|
||||
error_message: 'The user is not friends with the authenticated user.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({friend, updated}));
|
||||
});
|
||||
|
||||
app.get('/api/znc/friend/:nsid/presence', localAuth, nsoAuth, getAppData, async (req, res) => {
|
||||
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
|
||||
const friend = friends.find(f => f.nsaId === req.params.nsid);
|
||||
|
||||
if (!friend) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: 'not_found',
|
||||
error_message: 'The user is not friends with the authenticated user.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(friend.presence));
|
||||
});
|
||||
|
||||
app.get('/api/znc/webservices', localAuth, nsoAuth, getAppData, async (req, res) => {
|
||||
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({webservices, updated}));
|
||||
});
|
||||
|
||||
app.get('/api/znc/webservice/:id/token', nsoAuth, async (req, res) => {
|
||||
try {
|
||||
const response = await req.znc!.getWebServiceToken(req.params.id, req.zncAuth!.credential.accessToken);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
token: response.result,
|
||||
}));
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: err,
|
||||
error_message: (err as Error).message,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/znc/activeevent', localAuth, nsoAuth, getAppData, async (req, res) => {
|
||||
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({activeevent, updated}));
|
||||
});
|
||||
|
||||
//
|
||||
// Nintendo Switch user data
|
||||
//
|
||||
|
||||
const cached_userdata = new Map<string, [CurrentUser, number]>();
|
||||
|
||||
const getUserData: express.RequestHandler = async (req, res, next) => {
|
||||
const cache = cached_userdata.get(req.zncAuth!.user.id);
|
||||
|
||||
if (cache && ((cache[1] + updateInterval) > Date.now())) {
|
||||
debug('Using cached user data for %s', req.zncAuth!.user.id);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await req.znc!.getCurrentUser();
|
||||
cached_userdata.set(req.zncAuth!.user.id, [user.result, Date.now()]);
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: err,
|
||||
error_message: (err as Error).message,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/znc/user', localAuth, nsoAuth, getUserData, async (req, res) => {
|
||||
const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({user, updated}));
|
||||
});
|
||||
|
||||
app.get('/api/znc/user/presence', localAuth, nsoAuth, getUserData, async (req, res) => {
|
||||
const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(user.presence));
|
||||
});
|
||||
|
||||
//
|
||||
// Event stream
|
||||
//
|
||||
|
||||
app.get('/api/znc/presence/events', localAuth, nsoAuth, async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
|
||||
const nintendoAccountSessionToken = req.headers['authorization']!.substr(3);
|
||||
const i = new ZncPresenceEventStream(
|
||||
argv as any, storage, nintendoAccountSessionToken, req.znc!, req.zncAuth!,
|
||||
req, res,
|
||||
true, true,
|
||||
);
|
||||
|
||||
await i.init();
|
||||
|
||||
while (true) {
|
||||
await i.loop();
|
||||
}
|
||||
});
|
||||
|
||||
for (const address of argv.listen) {
|
||||
const match = address.match(/^(?:((?:\d+\.){3}\d+)|\[(.*)\]):(\d+)$/);
|
||||
if (!match || !net.isIP(match[1] || match[2])) throw new Error('Not a valid address/port');
|
||||
|
||||
const server = app.listen(parseInt(match[3]), match[1] || match[2]);
|
||||
server.on('listening', () => {
|
||||
const address = server.address() as net.AddressInfo;
|
||||
console.log('Listening on %s, port %d', address.address, address.port);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export enum ZncPresenceEventStreamEvent {
|
||||
FRIEND_ONLINE = '0',
|
||||
FRIEND_OFFLINE = '1',
|
||||
FRIEND_TITLE_CHANGE = '2',
|
||||
FRIEND_TITLE_STATECHANGE = '3',
|
||||
}
|
||||
|
||||
class ZncPresenceEventStream extends ZncNotifications {
|
||||
constructor(
|
||||
argv: ArgumentsCamelCase<Arguments>,
|
||||
storage: persist.LocalStorage,
|
||||
token: string,
|
||||
nso: ZncApi,
|
||||
data: Omit<SavedToken, 'expires_at'>,
|
||||
public req: express.Request,
|
||||
public res: express.Response,
|
||||
user: boolean,
|
||||
friend: boolean,
|
||||
) {
|
||||
super({
|
||||
...argv,
|
||||
user: undefined,
|
||||
token: undefined,
|
||||
'user-notifications': user,
|
||||
userNotifications: user,
|
||||
'friend-notifications': friend,
|
||||
friendNotifications: friend,
|
||||
}, storage, token, nso, data);
|
||||
}
|
||||
|
||||
sendEvent(event: string | null, ...data: unknown[]) {
|
||||
if (event) this.res.write('event: ' + event + '\n');
|
||||
for (const d of data) this.res.write('data: ' + JSON.stringify(d) + '\n');
|
||||
this.res.write('\n');
|
||||
}
|
||||
|
||||
onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
|
||||
this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_ONLINE, {
|
||||
id: friend.nsaId, presence: friend.presence, prev: prev?.presence,
|
||||
});
|
||||
}
|
||||
|
||||
onFriendOffline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
|
||||
this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_OFFLINE, {
|
||||
id: friend.nsaId, presence: friend.presence, prev: prev?.presence,
|
||||
});
|
||||
}
|
||||
|
||||
onFriendPlayingChangeTitle(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
|
||||
this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_TITLE_CHANGE, {
|
||||
id: friend.nsaId, presence: friend.presence, prev: prev?.presence,
|
||||
});
|
||||
}
|
||||
|
||||
onFriendTitleStateChange(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
|
||||
this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_TITLE_STATECHANGE, {
|
||||
id: friend.nsaId, presence: friend.presence, prev: prev?.presence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -7,3 +7,4 @@ export * as webservicetoken from './webservicetoken.js';
|
|||
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';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { CurrentUser, Friend, Game, PresenceState } from '../api/znc-types.js';
|
|||
import ZncApi from '../api/znc.js';
|
||||
import type { Arguments as ParentArguments } from '../cli.js';
|
||||
import { ArgumentsCamelCase, Argv, getTitleIdFromEcUrl, getToken, initStorage, SavedToken, YargsArguments } from '../util.js';
|
||||
import ZncProxyApi from '../api/znc-proxy.js';
|
||||
|
||||
const debug = createDebug('cli:notify');
|
||||
const debugFriends = createDebug('cli:notify:friends');
|
||||
|
|
@ -47,7 +48,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
|
||||
await storage.getItem('SessionToken');
|
||||
const {nso, data} = await getToken(storage, token);
|
||||
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
|
||||
|
||||
const i = new ZncNotifications(argv, storage, token, nso, data);
|
||||
|
||||
|
|
@ -72,9 +73,12 @@ export class ZncNotifications {
|
|||
|
||||
async init() {
|
||||
const announcements = await this.nso.getAnnouncements();
|
||||
const friends = await this.nso.getFriendList();
|
||||
const webservices = await this.nso.getWebServices();
|
||||
const activeevent = await this.nso.getActiveEvent();
|
||||
const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ?
|
||||
await this.nso.getFriendList() : {result: {friends: []}};
|
||||
if (!(this.nso instanceof ZncProxyApi)) {
|
||||
const webservices = await this.nso.getWebServices();
|
||||
const activeevent = await this.nso.getActiveEvent();
|
||||
}
|
||||
|
||||
if (this.argv.userNotifications) {
|
||||
const user = await this.nso.getCurrentUser();
|
||||
|
|
@ -88,13 +92,58 @@ export class ZncNotifications {
|
|||
await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000));
|
||||
}
|
||||
|
||||
onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
|
||||
const currenttitle = friend.presence.game as Game;
|
||||
|
||||
notifier.notify({
|
||||
title: friend.name,
|
||||
message: 'Playing ' + currenttitle.name +
|
||||
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
|
||||
// icon: currenttitle.imageUri,
|
||||
icon: friend.imageUri,
|
||||
});
|
||||
}
|
||||
|
||||
onFriendOffline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
|
||||
notifier.notify({
|
||||
title: friend.name,
|
||||
message: 'Offline',
|
||||
icon: friend.imageUri,
|
||||
});
|
||||
}
|
||||
|
||||
onFriendPlayingChangeTitle(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
|
||||
const currenttitle = friend.presence.game as Game;
|
||||
|
||||
notifier.notify({
|
||||
title: friend.name,
|
||||
message: 'Playing ' + currenttitle.name +
|
||||
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
|
||||
// icon: currenttitle.imageUri,
|
||||
icon: friend.imageUri,
|
||||
});
|
||||
}
|
||||
|
||||
onFriendTitleStateChange(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
|
||||
const currenttitle = friend.presence.game as Game;
|
||||
|
||||
notifier.notify({
|
||||
title: friend.name,
|
||||
message: 'Playing ' + currenttitle.name +
|
||||
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
|
||||
// icon: currenttitle.imageUri,
|
||||
icon: friend.imageUri,
|
||||
});
|
||||
}
|
||||
|
||||
onlinefriends: (CurrentUser | Friend)[] = [];
|
||||
|
||||
async updateFriendsStatusForNotifications(friends: (CurrentUser | Friend)[], initialRun?: boolean) {
|
||||
const newonlinefriends: (CurrentUser | Friend)[] = [];
|
||||
|
||||
for (const friend of friends) {
|
||||
const lastpresence = this.onlinefriends.find(f => f.id === friend.id)?.presence;
|
||||
const prev = this.onlinefriends.find(f => f.id === friend.id);
|
||||
const lastpresence = prev?.presence;
|
||||
const online = friend.presence.state === PresenceState.ONLINE ||
|
||||
friend.presence.state === PresenceState.PLAYING;
|
||||
|
||||
|
|
@ -105,25 +154,17 @@ export class ZncNotifications {
|
|||
debugFriends('%s is now online, title %s %s', friend.name,
|
||||
currenttitle.name, JSON.stringify(currenttitle.sysDescription));
|
||||
|
||||
notifier.notify({
|
||||
title: friend.name,
|
||||
message: 'Playing ' + currenttitle.name +
|
||||
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
|
||||
icon: currenttitle.imageUri,
|
||||
});
|
||||
this.onFriendOnline(friend, prev, initialRun);
|
||||
|
||||
newonlinefriends.push(friend);
|
||||
} else if (lastpresence && !online) {
|
||||
// Friend has gone offline
|
||||
const lasttitle = lastpresence.game as Game;
|
||||
|
||||
notifier.notify({
|
||||
title: friend.name,
|
||||
message: 'Offline',
|
||||
});
|
||||
|
||||
debugFriends('%s is now offline, was playing title %s %s', friend.name,
|
||||
lasttitle.name, JSON.stringify(lasttitle.sysDescription));
|
||||
|
||||
this.onFriendOffline(friend, prev, initialRun);
|
||||
} else if (lastpresence && online) {
|
||||
// Friend is still online
|
||||
const lasttitle = lastpresence.game as Game;
|
||||
|
|
@ -137,12 +178,7 @@ export class ZncNotifications {
|
|||
currenttitle.name, JSON.stringify(currenttitle.sysDescription),
|
||||
lasttitle.name, JSON.stringify(lasttitle.sysDescription));
|
||||
|
||||
notifier.notify({
|
||||
title: friend.name,
|
||||
message: 'Playing ' + currenttitle.name +
|
||||
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
|
||||
icon: currenttitle.imageUri,
|
||||
});
|
||||
this.onFriendPlayingChangeTitle(friend, prev, initialRun);
|
||||
} else if (
|
||||
lastpresence.state !== friend.presence.state ||
|
||||
lasttitle.sysDescription !== currenttitle.sysDescription
|
||||
|
|
@ -154,12 +190,7 @@ export class ZncNotifications {
|
|||
friend.presence.state, JSON.stringify(currenttitle.sysDescription),
|
||||
lastpresence.state, JSON.stringify(lasttitle.sysDescription));
|
||||
|
||||
notifier.notify({
|
||||
title: friend.name,
|
||||
message: 'Playing ' + currenttitle.name +
|
||||
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
|
||||
icon: currenttitle.imageUri,
|
||||
});
|
||||
this.onFriendTitleStateChange(friend, prev, initialRun);
|
||||
}
|
||||
|
||||
newonlinefriends.push(friend);
|
||||
|
|
@ -173,9 +204,10 @@ export class ZncNotifications {
|
|||
debug('Updating presence');
|
||||
|
||||
if (this.argv.friendNotifications) {
|
||||
const activeevent = await this.nso.getActiveEvent();
|
||||
const friends = await this.nso.getFriendList();
|
||||
const webservices = await this.nso.getWebServices();
|
||||
if (!(this.nso instanceof ZncProxyApi)) await this.nso.getActiveEvent();
|
||||
const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ?
|
||||
await this.nso.getFriendList() : {result: {friends: []}};
|
||||
if (!(this.nso instanceof ZncProxyApi)) await this.nso.getWebServices();
|
||||
|
||||
if (this.argv.userNotifications) {
|
||||
const user = await this.nso.getCurrentUser();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import createDebug from 'debug';
|
||||
import persist from 'node-persist';
|
||||
import DiscordRPC from 'discord-rpc';
|
||||
import { CurrentUser, Presence, PresenceState } from '../api/znc-types.js';
|
||||
import fetch from 'node-fetch';
|
||||
import { CurrentUser, Friend, Presence, PresenceState } from '../api/znc-types.js';
|
||||
import ZncApi from '../api/znc.js';
|
||||
import type { Arguments as ParentArguments } from '../cli.js';
|
||||
import { ArgumentsCamelCase, Argv, getDiscordPresence, getTitleIdFromEcUrl, getToken, initStorage, SavedToken, YargsArguments } from '../util.js';
|
||||
import { ZncNotifications } from './notify.js';
|
||||
import ZncProxyApi from '../api/znc-proxy.js';
|
||||
|
||||
const debug = createDebug('cli:presence');
|
||||
const debugFriends = createDebug('cli:presence:friends');
|
||||
const debugProxy = createDebug('cli:presence:proxy');
|
||||
const debugDiscord = createDebug('cli:presence:discordrpc');
|
||||
|
||||
export const command = 'presence';
|
||||
|
|
@ -39,19 +41,42 @@ export function builder(yargs: Argv<ParentArguments>) {
|
|||
describe: 'Update interval in seconds',
|
||||
type: 'number',
|
||||
default: 30,
|
||||
}).option('presence-url', {
|
||||
describe: 'URL to get user presence from, for use with `nintendo-znc http-server`',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
if (argv.presenceUrl) {
|
||||
if (argv.token) throw new Error('--presence-url not compatible with --user');
|
||||
if (argv.token) throw new Error('--presence-url not compatible with --token');
|
||||
if (argv.friendNaid) throw new Error('--presence-url not compatible with --friend-naid');
|
||||
if (argv.userNotifications) throw new Error('--presence-url not compatible with --user-notifications');
|
||||
if (argv.friendNotifications) throw new Error('--presence-url not compatible with --user-notifications');
|
||||
|
||||
const i = new ZncProxyDiscordPresence(argv, argv.presenceUrl);
|
||||
|
||||
console.log('Not authenticated; using znc proxy');
|
||||
|
||||
await i.init();
|
||||
|
||||
while (true) {
|
||||
await i.loop();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = await initStorage(argv.dataPath);
|
||||
|
||||
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
|
||||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
|
||||
await storage.getItem('SessionToken');
|
||||
const {nso, data} = await getToken(storage, token);
|
||||
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
|
||||
|
||||
const i = new ZncDiscordPresence(argv, storage, token, nso, data);
|
||||
|
||||
|
|
@ -85,9 +110,23 @@ class ZncDiscordPresence extends ZncNotifications {
|
|||
|
||||
async init() {
|
||||
const announcements = await this.nso.getAnnouncements();
|
||||
const friends = await this.nso.getFriendList();
|
||||
const webservices = await this.nso.getWebServices();
|
||||
const activeevent = await this.nso.getActiveEvent();
|
||||
const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ?
|
||||
await this.nso.getFriendList() : {result: {friends: this.argv.friendNaid ? [this.argv.userNotifications ?
|
||||
(await this.nso.fetch<{friend: Friend}>('/friend/' + this.argv.friendNaid)).friend : {
|
||||
id: 0,
|
||||
nsaId: this.argv.friendNaid,
|
||||
imageUri: '',
|
||||
name: '',
|
||||
isFriend: true,
|
||||
isFavoriteFriend: false,
|
||||
isServiceUser: true,
|
||||
friendCreatedAt: 0,
|
||||
presence: await this.nso.fetch<Presence>('/friend/' + this.argv.friendNaid + '/presence'),
|
||||
}] : []}};
|
||||
if (!(this.nso instanceof ZncProxyApi)) {
|
||||
await this.nso.getWebServices();
|
||||
await this.nso.getActiveEvent();
|
||||
}
|
||||
|
||||
if (this.argv.friendNaid) {
|
||||
const friend = friends.result.friends.find(f => f.nsaId === this.argv.friendNaid);
|
||||
|
|
@ -235,9 +274,21 @@ class ZncDiscordPresence extends ZncNotifications {
|
|||
|
||||
async update() {
|
||||
if (this.argv.friendNaid) {
|
||||
const activeevent = await this.nso.getActiveEvent();
|
||||
const friends = await this.nso.getFriendList();
|
||||
const webservices = await this.nso.getWebServices();
|
||||
if (!(this.nso instanceof ZncProxyApi)) await this.nso.getActiveEvent();
|
||||
const friends = this.argv.friendNotifications || !(this.nso instanceof ZncProxyApi) ?
|
||||
await this.nso.getFriendList() : {result: {friends: this.argv.friendNaid ? [this.argv.userNotifications ?
|
||||
(await this.nso.fetch<{friend: Friend}>('/friend/' + this.argv.friendNaid)).friend : {
|
||||
id: 0,
|
||||
nsaId: this.argv.friendNaid,
|
||||
imageUri: '',
|
||||
name: '',
|
||||
isFriend: true,
|
||||
isFavoriteFriend: false,
|
||||
isServiceUser: true,
|
||||
friendCreatedAt: 0,
|
||||
presence: await this.nso.fetch<Presence>('/friend/' + this.argv.friendNaid + '/presence'),
|
||||
}] : []}};
|
||||
if (!(this.nso instanceof ZncProxyApi)) await this.nso.getWebServices();
|
||||
|
||||
const friend = friends.result.friends.find(f => f.nsaId === this.argv.friendNaid);
|
||||
|
||||
|
|
@ -258,13 +309,17 @@ class ZncDiscordPresence extends ZncNotifications {
|
|||
|
||||
await this.updatePresence(friend.presence);
|
||||
} else {
|
||||
const user = await this.nso.getCurrentUser();
|
||||
const user = !(this.nso instanceof ZncProxyApi) ?
|
||||
await this.nso.getCurrentUser() : {result: {
|
||||
...this.data.nsoAccount.user,
|
||||
presence: await this.nso.fetch<Presence>('/user/presence'),
|
||||
}};
|
||||
|
||||
if (this.argv.friendNotifications) {
|
||||
const activeevent = await this.nso.getActiveEvent();
|
||||
if (!(this.nso instanceof ZncProxyApi)) await this.nso.getActiveEvent();
|
||||
const friends = await this.nso.getFriendList();
|
||||
const webservices = await this.nso.getWebServices();
|
||||
|
||||
if (!(this.nso instanceof ZncProxyApi)) await this.nso.getWebServices();
|
||||
|
||||
await this.updateFriendsStatusForNotifications(this.argv.userNotifications ?
|
||||
[user.result, ...friends.result.friends] : friends.result.friends);
|
||||
} else if (this.argv.userNotifications) {
|
||||
|
|
@ -275,3 +330,30 @@ class ZncDiscordPresence extends ZncNotifications {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ZncProxyDiscordPresence extends ZncDiscordPresence {
|
||||
constructor(
|
||||
readonly argv: ArgumentsCamelCase<Arguments>,
|
||||
public presence_url: string
|
||||
) {
|
||||
super(argv, null!, null!, null!, null!);
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.update();
|
||||
|
||||
await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000));
|
||||
}
|
||||
|
||||
async update() {
|
||||
const response = await fetch(this.presence_url);
|
||||
debugProxy('fetch %s %s, response %s', 'GET', this.presence_url, response.status);
|
||||
if (response.status !== 200) {
|
||||
console.error('Non-200 status code', await response.text());
|
||||
throw new Error('Unknown error');
|
||||
}
|
||||
const presence = await response.json() as Presence;
|
||||
|
||||
await this.updatePresence(presence);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
await storage.setItem('SessionToken', argv.token);
|
||||
|
||||
if (argv.auth) {
|
||||
const {nso, data} = await getToken(storage, argv.token);
|
||||
const {nso, data} = await getToken(storage, argv.token, argv.zncProxyUrl);
|
||||
|
||||
console.log('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
|
||||
data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
|
||||
await storage.getItem('SessionToken');
|
||||
const {nso, data} = await getToken(storage, token);
|
||||
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
|
||||
|
||||
if (argv.forceRefresh && 'expires_at' in data) {
|
||||
const user = await nso.getCurrentUser();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function builder(yargs: Argv<ParentArguments>) {
|
|||
'Screen name',
|
||||
'Nickname',
|
||||
'Country',
|
||||
'Nintendo Switch ID',
|
||||
'Nintendo Switch username',
|
||||
],
|
||||
});
|
||||
|
|
@ -37,6 +38,7 @@ export function builder(yargs: Argv<ParentArguments>) {
|
|||
cache.user.screenName,
|
||||
cache.user.nickname,
|
||||
cache.user.country,
|
||||
cache.nsoAccount.user.nsaId,
|
||||
cache.nsoAccount.user.name,
|
||||
]);
|
||||
}
|
||||
|
|
@ -64,6 +66,10 @@ export function builder(yargs: Argv<ParentArguments>) {
|
|||
}
|
||||
|
||||
await storage.setItem('SelectedUser', argv.user);
|
||||
|
||||
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
|
||||
users.add(argv.user);
|
||||
await storage.setItem('NintendoAccountIds', [...users]);
|
||||
}).command('forget <user>', 'Removes all data for a Nintendo Account', yargs => {
|
||||
return yargs.positional('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
|
||||
await storage.getItem('SessionToken');
|
||||
const {nso, data} = await getToken(storage, token);
|
||||
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
|
||||
|
||||
const announcements = await nso.getAnnouncements();
|
||||
const friends = await nso.getFriendList();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid) ||
|
||||
await storage.getItem('SessionToken');
|
||||
const {nso, data} = await getToken(storage, token);
|
||||
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
|
||||
|
||||
const announcements = await nso.getAnnouncements();
|
||||
const friends = await nso.getFriendList();
|
||||
|
|
|
|||
21
src/util.ts
21
src/util.ts
|
|
@ -9,6 +9,7 @@ import { NintendoAccountToken, NintendoAccountUser } from './api/na.js';
|
|||
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';
|
||||
|
||||
const debug = createDebug('cli');
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ export interface SavedToken {
|
|||
credential: AccountLogin['webApiServerCredential'];
|
||||
|
||||
expires_at: number;
|
||||
proxy_url?: string;
|
||||
}
|
||||
|
||||
export async function initStorage(dir = path.join(import.meta.url.substr(7), '..', '..', 'data')) {
|
||||
|
|
@ -37,7 +39,7 @@ export async function initStorage(dir = path.join(import.meta.url.substr(7), '..
|
|||
return storage;
|
||||
}
|
||||
|
||||
export async function getToken(storage: persist.LocalStorage, token: string) {
|
||||
export async function getToken(storage: persist.LocalStorage, token: string, proxy_url?: string) {
|
||||
if (!token) {
|
||||
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nintendo-znc token`.');
|
||||
throw new Error('Invalid token');
|
||||
|
|
@ -49,23 +51,28 @@ export async function getToken(storage: persist.LocalStorage, token: string) {
|
|||
console.log('Authenticating to Nintendo Switch Online app');
|
||||
debug('Authenticating to znc with session token');
|
||||
|
||||
const data = await ZncApi.createWithSessionToken(token);
|
||||
const {nso, data} = proxy_url ?
|
||||
await ZncProxyApi.createWithSessionToken(proxy_url, token) :
|
||||
await ZncApi.createWithSessionToken(token);
|
||||
|
||||
const existingToken: SavedToken = {
|
||||
...data.data,
|
||||
expires_at: Date.now() + (data.data.credential.expiresIn * 1000),
|
||||
...data,
|
||||
expires_at: Date.now() + (data.credential.expiresIn * 1000),
|
||||
};
|
||||
|
||||
await storage.setItem('NsoToken.' + token, existingToken);
|
||||
await storage.setItem('NintendoAccountToken.' + data.data.user.id, token);
|
||||
await storage.setItem('NintendoAccountToken.' + data.user.id, token);
|
||||
|
||||
return data;
|
||||
return {nso, data: existingToken};
|
||||
}
|
||||
|
||||
debug('Using existing token');
|
||||
await storage.setItem('NintendoAccountToken.' + existingToken.user.id, token);
|
||||
|
||||
return {
|
||||
nso: new ZncApi(existingToken.credential.accessToken),
|
||||
nso: proxy_url ?
|
||||
new ZncProxyApi(proxy_url, token) :
|
||||
new ZncApi(existingToken.credential.accessToken),
|
||||
data: existingToken,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user