390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
import Axios, {AxiosError, AxiosResponse} from 'axios';
|
|
import * as qs from 'qs';
|
|
import {Connection as Interfaces, WebSocketConnection} from './interfaces';
|
|
import ReadyState = WebSocketConnection.ReadyState;
|
|
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
|
|
import core from '../chat/core';
|
|
import throat from 'throat';
|
|
|
|
const fatalErrors = [2, 3, 4, 9, 30, 31, 33, 39, 40, 62, -4];
|
|
const dieErrors = [9, 30, 31, 39, 40];
|
|
|
|
let lastFetch = Date.now();
|
|
let lastApiTicketFetch = Date.now();
|
|
|
|
const queryApiThroat = throat(2);
|
|
const queryTicketThroat = throat(1);
|
|
|
|
|
|
async function queryApi(this: void, endpoint: string, data: object): Promise<AxiosResponse> {
|
|
lastFetch = Date.now();
|
|
|
|
return Axios.post(`https://www.f-list.net/json/api/${endpoint}`, qs.stringify(data));
|
|
}
|
|
|
|
export default class Connection implements Interfaces.Connection {
|
|
character = '';
|
|
vars: Interfaces.Vars = <any>{}; //tslint:disable-line:no-any
|
|
protected socket: WebSocketConnection | undefined = undefined;
|
|
//tslint:disable-next-line:no-object-literal-type-assertion
|
|
private messageHandlers = <{ [key in keyof Interfaces.ServerCommands]: Interfaces.CommandHandler<key>[] }>{};
|
|
private connectionHandlers: { [key in Interfaces.EventType]?: Interfaces.EventHandler[] } = {};
|
|
private errorHandlers: ((error: Error) => void)[] = [];
|
|
private ticket = '';
|
|
private cleanClose = false;
|
|
private reconnectTimer: NodeJS.Timer | undefined;
|
|
private account = '';
|
|
private ticketProvider?: Interfaces.TicketProvider;
|
|
private reconnectDelay = 0;
|
|
private isReconnect = false;
|
|
private pinTimeout?: NodeJS.Timer;
|
|
|
|
constructor(private readonly clientName: string, private readonly version: string,
|
|
private readonly socketProvider: new() => WebSocketConnection) {
|
|
}
|
|
|
|
setCredentials(account: string, ticketProvider: Interfaces.TicketProvider | string): void {
|
|
this.account = account;
|
|
this.ticketProvider = typeof ticketProvider === 'string' ? async() => this.getTicket(ticketProvider) : ticketProvider;
|
|
}
|
|
|
|
async connect(character: string): Promise<void> {
|
|
if(!this.ticketProvider) throw new Error('No credentials set!');
|
|
this.cleanClose = false;
|
|
if(this.character !== character) this.isReconnect = false;
|
|
this.character = character;
|
|
try {
|
|
this.ticket = await this.ticketProvider();
|
|
} catch(e) {
|
|
if(this.reconnectTimer !== undefined)
|
|
if((<AxiosError>e).request !== undefined) this.reconnect();
|
|
else await this.invokeHandlers('closed', false);
|
|
return this.invokeErrorHandlers(<Error>e, true);
|
|
}
|
|
try {
|
|
await this.invokeHandlers('connecting', this.isReconnect);
|
|
} catch(e) {
|
|
await this.invokeHandlers('closed', false);
|
|
return this.invokeErrorHandlers(<Error>e);
|
|
}
|
|
if(this.cleanClose) {
|
|
this.cleanClose = false;
|
|
await this.invokeHandlers('closed', false);
|
|
return;
|
|
}
|
|
try {
|
|
this.socket = new this.socketProvider();
|
|
} catch(e) {
|
|
await this.invokeHandlers('closed', false);
|
|
return this.invokeErrorHandlers(<Error>e, true);
|
|
}
|
|
this.socket.onOpen(() => {
|
|
this.send('IDN', {
|
|
account: this.account,
|
|
character: this.character,
|
|
cname: this.clientName,
|
|
cversion: this.version,
|
|
method: 'ticket',
|
|
ticket: this.ticket
|
|
});
|
|
this.resetPinTimeout();
|
|
});
|
|
this.socket.onMessage(async(msg: string) => {
|
|
const type = <keyof Interfaces.ServerCommands>msg.substr(0, 3);
|
|
const data = msg.length > 6 ? <object>JSON.parse(msg.substr(4)) : undefined;
|
|
|
|
log.silly(
|
|
'socket.recv',
|
|
{
|
|
type, data
|
|
}
|
|
);
|
|
|
|
return this.handleMessage(type, data);
|
|
});
|
|
this.socket.onClose(async(event: CloseEvent) => {
|
|
log.debug(
|
|
'socket.onclose',
|
|
{
|
|
code: event.code,
|
|
reason: event.reason,
|
|
wasClean: event.wasClean,
|
|
event
|
|
}
|
|
);
|
|
|
|
if(this.pinTimeout) clearTimeout(this.pinTimeout);
|
|
if(!this.cleanClose) this.reconnect();
|
|
this.socket = undefined;
|
|
await this.invokeHandlers('closed', !this.cleanClose);
|
|
});
|
|
this.socket.onError((error: Error) => this.invokeErrorHandlers(error, true));
|
|
}
|
|
|
|
private reconnect(): void {
|
|
this.reconnectTimer = setTimeout(async() => this.connect(this.character), this.reconnectDelay);
|
|
this.reconnectDelay = this.reconnectDelay >= 30000 ? 60000 : this.reconnectDelay >= 10000 ? 30000 : 10000;
|
|
}
|
|
|
|
close(keepState: boolean = true): void {
|
|
if(this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = undefined;
|
|
this.cleanClose = true;
|
|
if(this.socket !== undefined) this.socket.close();
|
|
|
|
if(!keepState) {
|
|
this.character = '';
|
|
}
|
|
}
|
|
|
|
|
|
get isOpen(): boolean {
|
|
return this.socket !== undefined && this.socket.readyState === ReadyState.OPEN;
|
|
}
|
|
|
|
|
|
async queryApi<T = object>(endpoint: string, data?: {account?: string, ticket?: string}): Promise<T> {
|
|
return queryApiThroat(async() => this.queryApiExec<T>(endpoint, data));
|
|
}
|
|
|
|
|
|
protected async refreshTicket(oldTicket: string): Promise<string> {
|
|
if (this.ticket !== oldTicket) {
|
|
log.debug(
|
|
'api.ticket.renew.resolve.cache',
|
|
{
|
|
character: core.characters.ownCharacter?.name
|
|
}
|
|
);
|
|
|
|
return this.ticket;
|
|
}
|
|
|
|
if (!this.ticketProvider) {
|
|
throw new Error('No credentials set!');
|
|
}
|
|
|
|
this.ticket = await queryTicketThroat(async() => this.ticketProvider!());
|
|
|
|
log.debug(
|
|
'api.ticket.renew.resolve.refresh',
|
|
{
|
|
character: core.characters.ownCharacter?.name
|
|
}
|
|
);
|
|
|
|
return this.ticket;
|
|
}
|
|
|
|
|
|
protected async queryApiExec<T = object>(endpoint: string, data?: {account?: string, ticket?: string}): Promise<T> {
|
|
if(!this.ticketProvider) throw new Error('No credentials set!');
|
|
|
|
log.debug(
|
|
'api.query.start',
|
|
{
|
|
endpoint,
|
|
data,
|
|
character: core.characters.ownCharacter?.name,
|
|
deltaToLastApiCall: Date.now() - lastFetch,
|
|
deltaToLastApiTicket: Date.now() - lastApiTicketFetch
|
|
}
|
|
);
|
|
|
|
if(data === undefined) data = {};
|
|
|
|
data.account = this.account;
|
|
data.ticket = this.ticket;
|
|
|
|
let res = <T & {error: string}>(await queryApi(endpoint, data)).data;
|
|
|
|
if(res.error === 'Invalid ticket.' || res.error === 'Your login ticket has expired (five minutes) or no ticket requested.') {
|
|
log.debug(
|
|
'api.ticket.loss',
|
|
{
|
|
error: res.error,
|
|
character: core.characters.ownCharacter?.name,
|
|
deltaToLastApiCall: Date.now() - lastFetch,
|
|
deltaToLastApiTicket: Date.now() - lastApiTicketFetch
|
|
}
|
|
);
|
|
|
|
data.ticket = await this.refreshTicket(data.ticket);
|
|
res = <T & {error: string}>(await queryApi(endpoint, data)).data;
|
|
}
|
|
|
|
if(res.error !== '') {
|
|
log.debug(
|
|
'api.query.error',
|
|
{
|
|
error: res.error,
|
|
endpoint,
|
|
data,
|
|
character: core.characters.ownCharacter?.name,
|
|
deltaToLastApiCall: Date.now() - lastFetch,
|
|
deltaToLastApiTicket: Date.now() - lastApiTicketFetch
|
|
}
|
|
);
|
|
|
|
const error = new Error(res.error);
|
|
(<Error & {request: true}>error).request = true;
|
|
throw error;
|
|
}
|
|
|
|
log.debug(
|
|
'api.query.success',
|
|
{
|
|
endpoint,
|
|
data,
|
|
character: core.characters.ownCharacter?.name,
|
|
deltaToLastApiCall: Date.now() - lastFetch,
|
|
deltaToLastApiTicket: Date.now() - lastApiTicketFetch
|
|
}
|
|
);
|
|
|
|
return res;
|
|
}
|
|
|
|
onError(handler: (error: Error) => void): void {
|
|
this.errorHandlers.push(handler);
|
|
}
|
|
|
|
onEvent(type: Interfaces.EventType, handler: Interfaces.EventHandler): void {
|
|
let handlers = this.connectionHandlers[type];
|
|
if(handlers === undefined) handlers = this.connectionHandlers[type] = [];
|
|
handlers.push(handler);
|
|
}
|
|
|
|
offEvent(type: Interfaces.EventType, handler: Interfaces.EventHandler): void {
|
|
const handlers = this.connectionHandlers[type];
|
|
if(handlers === undefined) return;
|
|
handlers.splice(handlers.indexOf(handler), 1);
|
|
}
|
|
|
|
onMessage<K extends keyof Interfaces.ServerCommands>(type: K, handler: Interfaces.CommandHandler<K>): void {
|
|
let handlers = <Interfaces.CommandHandler<K>[] | undefined>this.messageHandlers[type];
|
|
if(handlers === undefined) handlers = this.messageHandlers[type] = [];
|
|
handlers.push(handler);
|
|
}
|
|
|
|
offMessage<K extends keyof Interfaces.ServerCommands>(type: K, handler: Interfaces.CommandHandler<K>): void {
|
|
const handlers = <Interfaces.CommandHandler<K>[] | undefined>this.messageHandlers[type];
|
|
if(handlers === undefined) return;
|
|
handlers.splice(handlers.indexOf(handler), 1);
|
|
}
|
|
|
|
send<K extends keyof Interfaces.ClientCommands>(command: K, data?: Interfaces.ClientCommands[K]): void {
|
|
if(this.socket !== undefined && this.socket.readyState === WebSocketConnection.ReadyState.OPEN) {
|
|
const msg = <string>command + (data !== undefined ? ` ${JSON.stringify(data)}` : '');
|
|
|
|
log.silly('socket.send', { data: msg });
|
|
|
|
this.socket.send(msg);
|
|
}
|
|
}
|
|
|
|
//tslint:disable:no-unsafe-any no-any
|
|
protected async handleMessage<T extends keyof Interfaces.ServerCommands>(type: T, data: any): Promise<void> {
|
|
const time = new Date();
|
|
const handlers = <Interfaces.CommandHandler<T>[] | undefined>this.messageHandlers[type];
|
|
if(handlers !== undefined)
|
|
for(const handler of handlers) await handler(data, time);
|
|
switch(type) {
|
|
case 'VAR':
|
|
this.vars[<keyof Interfaces.Vars>data.variable] = data.value;
|
|
break;
|
|
case 'PIN':
|
|
this.send('PIN');
|
|
this.resetPinTimeout();
|
|
break;
|
|
case 'ERR':
|
|
if(fatalErrors.indexOf(data.number) !== -1) {
|
|
this.invokeErrorHandlers(new Error(data.message), true);
|
|
if(dieErrors.indexOf(data.number) !== -1) {
|
|
this.close();
|
|
this.character = '';
|
|
} else this.socket!.close();
|
|
}
|
|
break;
|
|
case 'NLN':
|
|
if(data.identity === this.character) {
|
|
await this.invokeHandlers('connected', this.isReconnect);
|
|
this.reconnectDelay = 0;
|
|
this.isReconnect = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
//tslint:enable
|
|
|
|
private async getTicket(password: string): Promise<string> {
|
|
console.log('Acquiring new API ticket');
|
|
const oldLastApiTicketFetch = lastApiTicketFetch;
|
|
|
|
log.debug(
|
|
'api.getTicket.start',
|
|
{
|
|
character: core.characters.ownCharacter?.name,
|
|
deltaToLastApiCall: Date.now() - lastFetch,
|
|
deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch
|
|
}
|
|
);
|
|
|
|
lastApiTicketFetch = Date.now();
|
|
|
|
const data = <{ticket?: string, error: string}>(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify(
|
|
{account: this.account, password, no_friends: true, no_bookmarks: true, no_characters: true}))).data;
|
|
|
|
if(data.ticket !== undefined) {
|
|
log.debug(
|
|
'api.getTicket.success',
|
|
{
|
|
character: core.characters.ownCharacter?.name,
|
|
deltaToLastApiCall: Date.now() - lastFetch,
|
|
deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch
|
|
}
|
|
);
|
|
|
|
return data.ticket;
|
|
}
|
|
|
|
|
|
console.error('API Ticket Error', data.error);
|
|
|
|
log.error(
|
|
'error.api.getTicket',
|
|
{
|
|
character: core.characters.ownCharacter.name,
|
|
error: data.error,
|
|
deltaToLastApiCall: Date.now() - lastFetch,
|
|
deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch
|
|
}
|
|
);
|
|
|
|
throw new Error(data.error);
|
|
}
|
|
|
|
private async invokeHandlers(type: Interfaces.EventType, isReconnect: boolean): Promise<void> {
|
|
const handlers = this.connectionHandlers[type];
|
|
if(handlers === undefined) return;
|
|
for(const handler of handlers) await handler(isReconnect);
|
|
}
|
|
|
|
private invokeErrorHandlers(error: Error, request: boolean = false): void {
|
|
if(request) (<Error & {request: true}>error).request = true;
|
|
for(const handler of this.errorHandlers) handler(error);
|
|
}
|
|
|
|
private resetPinTimeout(): void {
|
|
if(this.pinTimeout) clearTimeout(this.pinTimeout);
|
|
|
|
this.pinTimeout = setTimeout(
|
|
() => {
|
|
log.error('pin.timeout');
|
|
this.socket!.close();
|
|
},
|
|
90000
|
|
);
|
|
}
|
|
}
|